diff --git a/Cargo.lock b/Cargo.lock index 8f75b380..f748a92c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,6 +1499,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "flate2" version = "1.1.1" @@ -2744,9 +2755,8 @@ dependencies = [ [[package]] name = "kcl-derive-docs" -version = "0.2.138" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab70ad08996240a1b1a703e45684c675eda3616d52db6a71c17d9fc4dcc6d90b" +version = "0.2.136" +source = "git+https://github.com/KittyCAD/modeling-app?branch=main#5fef43054e567674b74248fdba6bd4a4824e00be" dependencies = [ "proc-macro2", "quote", @@ -2755,9 +2765,8 @@ dependencies = [ [[package]] name = "kcl-error" -version = "0.2.138" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6de33ca503e032d4b6f3020fe298707bdaad8bd16d0e4bc89588f7ea67436a" +version = "0.2.136" +source = "git+https://github.com/KittyCAD/modeling-app?branch=main#5fef43054e567674b74248fdba6bd4a4824e00be" dependencies = [ "miette", "serde", @@ -2767,9 +2776,8 @@ dependencies = [ [[package]] name = "kcl-lib" -version = "0.2.138" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83205ebe36647cc2d494b73aa399ba0bd6cdf2b86fa9203cd5ba1ac54f0a0c8d" +version = "0.2.136" +source = "git+https://github.com/KittyCAD/modeling-app?branch=main#5fef43054e567674b74248fdba6bd4a4824e00be" dependencies = [ "ahash", "anyhow", @@ -2841,9 +2849,8 @@ dependencies = [ [[package]] name = "kcl-test-server" -version = "0.2.138" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efef481f9dd4bddd6e307b2ce28be49bc0e628d65e8261dab706563c823aa9c8" +version = "0.2.136" +source = "git+https://github.com/KittyCAD/modeling-app?branch=main#5fef43054e567674b74248fdba6bd4a4824e00be" dependencies = [ "anyhow", "hyper 0.14.32", @@ -2857,8 +2864,7 @@ dependencies = [ [[package]] name = "kittycad" version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25f584622bc2ae682b0f64115fa9bfa26044ee71a8a7cf3ec8a207d35569202" +source = "git+https://github.com/KittyCAD/kittycad.rs?branch=main#6bfa6a848a35a019a003c04f518df3d8d64e1064" dependencies = [ "anyhow", "async-trait", @@ -3007,6 +3013,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.5.11", ] [[package]] @@ -3567,7 +3574,7 @@ dependencies = [ [[package]] name = "openapitor" version = "0.0.9" -source = "git+https://github.com/KittyCAD/kittycad.rs?branch=main#da04836f04550393bab2d4f774b37a85d94c2258" +source = "git+https://github.com/KittyCAD/kittycad.rs?branch=main#6bfa6a848a35a019a003c04f518df3d8d64e1064" dependencies = [ "Inflector", "anyhow", @@ -5581,6 +5588,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -7075,6 +7093,16 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "y4m" version = "0.8.0" @@ -7250,6 +7278,7 @@ dependencies = [ "git_rev", "heck", "http 1.4.0", + "ignore", "image", "itertools", "kcl-lib", @@ -7283,6 +7312,7 @@ dependencies = [ "slog-term", "tabled", "tabwriter", + "tar", "tempfile", "terminal_size", "test-context", diff --git a/Cargo.toml b/Cargo.toml index 555b03e2..07bbc379 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,14 +32,15 @@ futures = "0.3" git_rev = "0.1.0" heck = "0.5.0" http = "1" +ignore = "0.4" image = { version = "0.25", default-features = false, features = [ "png", "jpeg", ] } itertools = "0.14.0" -kcl-lib = { version = "=0.2.138", features = ["disable-println"] } -kcl-test-server = "=0.2.138" -kittycad = { version = "0.4.9", features = [ +kcl-lib = { git = "https://github.com/KittyCAD/modeling-app", branch = "main", features = ["disable-println"] } +kcl-test-server = { git = "https://github.com/KittyCAD/modeling-app", branch = "main" } +kittycad = { git = "https://github.com/KittyCAD/kittycad.rs", branch = "main", features = [ "clap", "tabled", "requests", @@ -84,6 +85,7 @@ slog-term = "2" tabled = { version = "0.20.0", features = ["ansi"] } tabwriter = "1.4.1" tempfile = "3.27.0" +tar = "0.4" terminal_size = "0.4.3" thiserror = "2" tokio = { version = "1", features = ["full"] } @@ -118,5 +120,6 @@ incremental = true debug = 0 [patch.crates-io] +kittycad = { git = "https://github.com/KittyCAD/kittycad.rs", branch = "main" } + # kittycad-modeling-cmds = { git = "https://github.com/KittyCAD/modeling-api", branch = "achalmers/remove-cruft"} -# kcl-lib = { path = "../modeling-app/rust/kcl-lib" } diff --git a/flake.lock b/flake.lock index de14e5b8..500a05be 100644 --- a/flake.lock +++ b/flake.lock @@ -28,11 +28,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1752689277, - "narHash": "sha256-uldUBFkZe/E7qbvxa3mH1ItrWZyT6w1dBKJQF/3ZSsc=", + "lastModified": 1769799857, + "narHash": "sha256-88IFXZ7Sa1vxbz5pty0Io5qEaMQMMUPMonLa3Ls/ss4=", "owner": "nix-community", "repo": "naersk", - "rev": "0e72363d0938b0208d6c646d10649164c43f4d64", + "rev": "9d4ed44d8b8cecdceb1d6fd76e74123d90ae6339", "type": "github" }, "original": { @@ -59,11 +59,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1762604901, - "narHash": "sha256-Pr2jpryIaQr9Yx8p6QssS03wqB6UifnnLr3HJw9veDw=", + "lastModified": 1775095191, + "narHash": "sha256-CsqRiYbgQyv01LS0NlC7shwzhDhjNDQSrhBX8VuD3nM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "f6b44b2401525650256b977063dbcf830f762369", + "rev": "106eb93cbb9d4e4726bf6bc367a3114f7ed6b32f", "type": "github" }, "original": { @@ -118,11 +118,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1762915112, - "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=", + "lastModified": 1775099554, + "narHash": "sha256-3xBsGnGDLOFtnPZ1D3j2LU19wpAlYefRKTlkv648rU0=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02", + "rev": "8d6387ed6d8e6e6672fd3ed4b61b59d44b124d99", "type": "github" }, "original": { diff --git a/src/cmd_project.rs b/src/cmd_project.rs new file mode 100644 index 00000000..dfbe2201 --- /dev/null +++ b/src/cmd_project.rs @@ -0,0 +1,500 @@ +use std::{io::Write, path::PathBuf}; + +use anyhow::{Context as _, Result}; +use clap::Parser; + +use crate::types::FormatOutput; + +/// Manage Zoo projects. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProject { + #[clap(subcommand)] + subcmd: SubCommand, +} + +#[derive(Parser, Debug, Clone)] +enum SubCommand { + Categories(CmdProjectCategories), + Download(CmdProjectDownload), + List(CmdProjectList), + Publish(CmdProjectPublish), + #[clap(alias = "get")] + View(CmdProjectView), + Upload(CmdProjectUpload), + Update(CmdProjectUpdate), +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProject { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + match &self.subcmd { + SubCommand::Categories(cmd) => cmd.run(ctx).await, + SubCommand::Download(cmd) => cmd.run(ctx).await, + SubCommand::List(cmd) => cmd.run(ctx).await, + SubCommand::Publish(cmd) => cmd.run(ctx).await, + SubCommand::View(cmd) => cmd.run(ctx).await, + SubCommand::Upload(cmd) => cmd.run(ctx).await, + SubCommand::Update(cmd) => cmd.run(ctx).await, + } + } +} + +/// List the active project categories available for submission. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectCategories { + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectCategories { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let client = ctx.api_client("")?; + let categories = client.users().list_project_categories().await?; + let format = ctx.format(&self.format)?; + ctx.io.write_output_for_vec(&format, categories)?; + Ok(()) + } +} + +/// Download one of your projects into a local directory. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectDownload { + /// The project id. + #[clap(name = "id", required = true)] + pub id: uuid::Uuid, + + /// The directory to extract the project into. + #[clap(name = "output-dir", default_value = ".")] + pub output_dir: PathBuf, + + /// Allow extracting into a non-empty destination, overwriting existing files in place. + #[clap(long, default_value = "false")] + pub force: bool, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectDownload { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + crate::project::ensure_download_destination(&self.output_dir, self.force)?; + + let client = ctx.api_client("")?; + let endpoint = format!("/user/projects/{}/download", self.id); + let req = client.request_raw(http::Method::GET, &endpoint, None).await?; + let resp = req.0.send().await?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("{} {}", status, body); + } + + let body = resp.bytes().await?; + let mut archive = tar::Archive::new(std::io::Cursor::new(body)); + archive + .unpack(&self.output_dir) + .with_context(|| format!("failed to extract archive into `{}`", self.output_dir.display()))?; + + if let Some(project_root) = crate::project::find_project_root_under(&self.output_dir)? { + let project_toml = project_root.join("project.toml"); + crate::project::persist_cloud_project_id(&project_toml, self.id)?; + writeln!( + ctx.io.out, + "{} Downloaded project {} into {}", + ctx.io.color_scheme().success_icon(), + self.id, + project_root.display() + )?; + } else { + writeln!( + ctx.io.out, + "{} Downloaded project {} into {}", + ctx.io.color_scheme().success_icon(), + self.id, + self.output_dir.display() + )?; + writeln!( + ctx.io.out, + "Could not locate a project root to persist the project id automatically." + )?; + } + + Ok(()) + } +} + +/// List your projects. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectList { + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[derive(Debug, Clone, serde::Serialize, tabled::Tabled)] +struct ProjectListTableRow { + title: String, + description: String, + id: uuid::Uuid, + #[tabled(rename = "updated")] + updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, serde::Serialize, tabled::Tabled)] +struct ProjectViewTableRow { + title: String, + description: String, + id: uuid::Uuid, + #[tabled(rename = "publication")] + publication_status: kittycad::types::KclProjectPublicationStatus, + #[tabled(rename = "files")] + file_count: usize, + #[tabled(rename = "created")] + created_at: chrono::DateTime, + #[tabled(rename = "updated")] + updated_at: chrono::DateTime, +} + +fn project_view_table_row(project: &kittycad::types::ProjectResponse) -> ProjectViewTableRow { + ProjectViewTableRow { + title: project.title.clone(), + description: project.description.clone(), + id: project.id, + publication_status: project.publication_status.clone(), + file_count: project.files.len(), + created_at: project.created_at, + updated_at: project.updated_at, + } +} + +fn write_project_output( + ctx: &mut crate::context::Context<'_>, + format: &FormatOutput, + project: &kittycad::types::ProjectResponse, +) -> Result<()> { + match format { + FormatOutput::Json => ctx.io.write_output_json(&serde_json::to_value(project)?)?, + FormatOutput::Yaml => ctx.io.write_output_yaml(project)?, + FormatOutput::Table => ctx + .io + .write_output_for_vec(format, vec![project_view_table_row(project)])?, + } + + Ok(()) +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectList { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let client = ctx.api_client("")?; + let projects = client.users().list_projects().await?; + let format = ctx.format(&self.format)?; + match format { + FormatOutput::Json => ctx.io.write_output_json(&serde_json::to_value(&projects)?)?, + FormatOutput::Yaml => ctx.io.write_output_yaml(&projects)?, + FormatOutput::Table => { + let rows = projects + .into_iter() + .map(|project| ProjectListTableRow { + title: project.title, + description: project.description, + id: project.id, + updated_at: project.updated_at, + }) + .collect::>(); + ctx.io.write_output_for_vec(&format, rows)? + } + } + Ok(()) + } +} + +/// View one of your projects. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectView { + /// The project id. + #[clap(name = "id", required = true)] + pub id: uuid::Uuid, + + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectView { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let client = ctx.api_client("")?; + let project = client.users().get_project(self.id).await?; + let format = ctx.format(&self.format)?; + write_project_output(ctx, &format, &project)?; + Ok(()) + } +} + +/// Submit an existing cloud project for publication review. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectPublish { + /// The project directory, a `.kcl` file within it, or `project.toml`. + /// + /// Used to look up the persisted Zoo cloud project id when `--id` is not passed. + #[clap(name = "input")] + pub input: Option, + + /// Override the persisted Zoo cloud project id from `project.toml`. + #[clap(long)] + pub id: Option, + + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectPublish { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let local = self + .input + .as_ref() + .map(|input| crate::project::resolve_local_project(input)) + .transpose()?; + let project_id = match (self.id, local.as_ref()) { + (Some(id), _) => id, + (None, Some(local)) => { + crate::project::read_persisted_cloud_project_id(&local.project_toml)?.with_context(|| { + format!( + "no Zoo cloud project id found in `{}`; pass `--id`", + local.project_toml.display() + ) + })? + } + (None, None) => anyhow::bail!("pass a local project path or `--id`"), + }; + + let client = ctx.api_client("")?; + let project = client.users().publish_project(project_id).await?; + + if let Some(local) = local { + crate::project::persist_cloud_project_id(&local.project_toml, project.id)?; + } + writeln!( + ctx.io.out, + "{} Submitted Zoo cloud project {} for publication review", + ctx.io.color_scheme().success_icon(), + project.id + )?; + + let format = ctx.format(&self.format)?; + write_project_output(ctx, &format, &project)?; + Ok(()) + } +} + +/// Upload a local project. +/// +/// If the local `project.toml` already contains a Zoo cloud project id, this +/// will update that project unless `--new` is passed. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectUpload { + /// The project directory, a `.kcl` file within it, or `project.toml`. + #[clap(name = "input", default_value = ".")] + pub input: PathBuf, + + /// Always create a new remote project even if one is already persisted locally. + #[clap(long, default_value = "false")] + pub new: bool, + + /// Title to use for the cloud project. Defaults to the local project directory name. + #[clap(long)] + pub title: Option, + + /// Description to use for the cloud project. Defaults to the existing remote description when updating. + #[clap(long)] + pub description: Option, + + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectUpload { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let local = crate::project::resolve_local_project(&self.input)?; + let existing_id = if self.new { + None + } else { + crate::project::read_persisted_cloud_project_id(&local.project_toml)? + }; + let attachments = crate::project::collect_project_attachments(&local.root)?; + let client = ctx.api_client("")?; + + let project = if let Some(id) = existing_id { + let existing = client.users().get_project(id).await?; + let body = ProjectUpsertBody { + title: self.title.clone().unwrap_or(existing.title), + description: self.description.clone().unwrap_or(existing.description), + }; + update_project_with_body(ctx, attachments, id, &body).await? + } else { + let body = ProjectUpsertBody { + title: self.title.clone().unwrap_or_else(|| default_project_title(&local.root)), + description: self.description.clone().unwrap_or_default(), + }; + create_project_with_body(ctx, attachments, &body).await? + }; + + crate::project::persist_cloud_project_id(&local.project_toml, project.id)?; + writeln!( + ctx.io.out, + "{} {} Zoo cloud project id {} in {}", + ctx.io.color_scheme().success_icon(), + if existing_id.is_some() { "Updated" } else { "Stored" }, + project.id, + local.project_toml.display() + )?; + + let format = ctx.format(&self.format)?; + write_project_output(ctx, &format, &project)?; + Ok(()) + } +} + +/// Replace an existing remote project with your local project files. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectUpdate { + /// The project directory, a `.kcl` file within it, or `project.toml`. + #[clap(name = "input", default_value = ".")] + pub input: PathBuf, + + /// Override the persisted Zoo cloud project id from `project.toml`. + #[clap(long)] + pub id: Option, + + /// Title to use for the cloud project. Defaults to the existing remote title. + #[clap(long)] + pub title: Option, + + /// Description to use for the cloud project. Defaults to the existing remote description. + #[clap(long)] + pub description: Option, + + /// Command output format. + #[clap(long, short, value_enum)] + pub format: Option, +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectUpdate { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let local = crate::project::resolve_local_project(&self.input)?; + let project_id = match self.id { + Some(id) => id, + None => crate::project::read_persisted_cloud_project_id(&local.project_toml)?.with_context(|| { + format!( + "no Zoo cloud project id found in `{}`; pass `--id`", + local.project_toml.display() + ) + })?, + }; + let attachments = crate::project::collect_project_attachments(&local.root)?; + let client = ctx.api_client("")?; + let existing = client.users().get_project(project_id).await?; + let body = ProjectUpsertBody { + title: self.title.clone().unwrap_or(existing.title), + description: self.description.clone().unwrap_or(existing.description), + }; + let project = update_project_with_body(ctx, attachments, project_id, &body).await?; + + crate::project::persist_cloud_project_id(&local.project_toml, project.id)?; + writeln!( + ctx.io.out, + "{} Stored Zoo cloud project id {} in {}", + ctx.io.color_scheme().success_icon(), + project.id, + local.project_toml.display() + )?; + + let format = ctx.format(&self.format)?; + write_project_output(ctx, &format, &project)?; + Ok(()) + } +} + +#[derive(Debug, Clone, serde::Serialize)] +struct ProjectUpsertBody { + title: String, + description: String, +} + +fn default_project_title(root: &std::path::Path) -> String { + root.file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("project") + .to_string() +} + +fn build_project_form( + attachments: Vec, + body: &ProjectUpsertBody, +) -> Result { + use std::convert::TryInto; + + let mut form = reqwest::multipart::Form::new(); + let mut json_part = reqwest::multipart::Part::text(serde_json::to_string(body)?); + json_part = json_part.file_name("body.json"); + json_part = json_part.mime_str("application/json")?; + form = form.part("body", json_part); + + for attachment in attachments { + form = form.part(attachment.name.clone(), attachment.try_into()?); + } + + Ok(form) +} + +async fn create_project_with_body( + ctx: &crate::context::Context<'_>, + attachments: Vec, + body: &ProjectUpsertBody, +) -> Result { + let req = ctx.raw_http_request("", reqwest::Method::POST, "/user/projects")?; + send_project_form(req, attachments, body).await +} + +async fn update_project_with_body( + ctx: &crate::context::Context<'_>, + attachments: Vec, + id: uuid::Uuid, + body: &ProjectUpsertBody, +) -> Result { + let endpoint = format!("/user/projects/{id}"); + let req = ctx.raw_http_request("", reqwest::Method::PUT, &endpoint)?; + send_project_form(req, attachments, body).await +} + +async fn send_project_form( + req: reqwest::RequestBuilder, + attachments: Vec, + body: &ProjectUpsertBody, +) -> Result { + let form = build_project_form(attachments, body)?; + let resp = req.multipart(form).send().await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + + if !status.is_success() { + anyhow::bail!("{} {}", status, text); + } + + serde_json::from_str(&text).with_context(|| format!("failed to parse project response body: {text}")) +} diff --git a/src/context.rs b/src/context.rs index 0b45bf9c..e64507bd 100644 --- a/src/context.rs +++ b/src/context.rs @@ -19,6 +19,34 @@ pub struct Context<'a> { } impl Context<'_> { + fn resolve_api_host_and_baseurl(&self, hostname: &str) -> Result<(String, String)> { + let host = if !hostname.is_empty() { + hostname.to_string() + } else if let Some(h) = &self.override_host { + h.clone() + } else { + self.config.default_host()? + }; + + let mut baseurl = host.to_string(); + if !host.starts_with("http://") && !host.starts_with("https://") { + baseurl = format!("https://{host}"); + if host.starts_with("localhost") { + baseurl = format!("http://{host}") + } + } + + Ok((host, baseurl)) + } + + fn http_client_builder(&self) -> reqwest::ClientBuilder { + let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),); + reqwest::Client::builder() + .user_agent(user_agent) + .timeout(std::time::Duration::from_secs(600)) + .connect_timeout(std::time::Duration::from_secs(60)) + } + pub fn new(config: &mut (dyn Config + Send + Sync)) -> Context<'_> { // Let's get our IO streams. let mut io = crate::iostreams::IoStreams::system(); @@ -60,35 +88,12 @@ impl Context<'_> { /// This function returns an API client for Zoo that is based on the configured /// user. pub fn api_client(&self, hostname: &str) -> Result { - // Resolution order: explicit arg > global override > default host from config - let host = if !hostname.is_empty() { - hostname.to_string() - } else if let Some(h) = &self.override_host { - h.clone() - } else { - self.config.default_host()? - }; - - // Change the baseURL to the one we want. - let mut baseurl = host.to_string(); - if !host.starts_with("http://") && !host.starts_with("https://") { - baseurl = format!("https://{host}"); - if host.starts_with("localhost") { - baseurl = format!("http://{host}") - } - } + let (host, baseurl) = self.resolve_api_host_and_baseurl(hostname)?; - let user_agent = concat!(env!("CARGO_PKG_NAME"), ".rs/", env!("CARGO_PKG_VERSION"),); - let http_client = reqwest::Client::builder() - .user_agent(user_agent) + let http_client = self.http_client_builder(); + let ws_client = self + .http_client_builder() // For file conversions we need this to be long. - .timeout(std::time::Duration::from_secs(600)) - .connect_timeout(std::time::Duration::from_secs(60)); - let ws_client = reqwest::Client::builder() - .user_agent(user_agent) - // For file conversions we need this to be long. - .timeout(std::time::Duration::from_secs(600)) - .connect_timeout(std::time::Duration::from_secs(60)) .tcp_keepalive(std::time::Duration::from_secs(600)) .http1_only(); @@ -105,6 +110,27 @@ impl Context<'_> { Ok(client) } + pub fn raw_http_request( + &self, + hostname: &str, + method: reqwest::Method, + uri: &str, + ) -> Result { + let (host, baseurl) = self.resolve_api_host_and_baseurl(hostname)?; + let token = self.config.get(&host, "token")?; + let client = self.http_client_builder().build()?; + let url = if uri.starts_with("https://") || uri.starts_with("http://") { + uri.to_string() + } else { + format!("{}/{}", baseurl.trim_end_matches('/'), uri.trim_start_matches('/')) + }; + + Ok(client.request(method, url).bearer_auth(token).header( + reqwest::header::ACCEPT, + reqwest::header::HeaderValue::from_static("application/json"), + )) + } + /// Return the global host override if set. pub fn global_host(&self) -> Option<&str> { self.override_host.as_deref() diff --git a/src/main.rs b/src/main.rs index c56f21bf..a8c28949 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,8 @@ pub mod cmd_kcl; pub mod cmd_ml; /// The open command. pub mod cmd_open; +/// The project command. +pub mod cmd_project; /// The say command. pub mod cmd_say; /// The start-session command. @@ -61,6 +63,7 @@ mod context; mod docs_markdown; mod iostreams; mod ml; +mod project; mod types; #[cfg(test)] @@ -150,6 +153,7 @@ enum SubCommand { Generate(cmd_generate::CmdGenerate), Kcl(cmd_kcl::CmdKcl), Ml(cmd_ml::CmdMl), + Project(cmd_project::CmdProject), Say(cmd_say::CmdSay), // Hide until is done. #[clap(hide = true)] @@ -284,6 +288,7 @@ async fn do_main(mut args: Vec, ctx: &mut crate::context::Context<'_>) - SubCommand::Generate(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Kcl(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Ml(cmd) => run_cmd(&cmd, ctx).await, + SubCommand::Project(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Say(cmd) => run_cmd(&cmd, ctx).await, SubCommand::StartSession(cmd) => run_cmd(&cmd, ctx).await, SubCommand::Open(cmd) => run_cmd(&cmd, ctx).await, diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 00000000..b972491e --- /dev/null +++ b/src/project.rs @@ -0,0 +1,389 @@ +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, +}; + +use anyhow::{Context as _, Result}; + +pub struct LocalProject { + pub root: PathBuf, + pub project_toml: PathBuf, +} + +const ZOO_TABLE_KEY: &str = "zoo"; +const ZOO_PROJECT_ID_KEY: &str = "project_id"; + +pub fn resolve_local_project(input: &Path) -> Result { + let input = normalize_input_path(input)?; + + let root = if input.is_dir() { + if let Some(project_toml) = crate::cmd_kcl::find_project_toml(&input)? { + project_toml + .parent() + .context("project.toml is missing a parent directory")? + .to_path_buf() + } else if input.join("main.kcl").exists() { + input + } else { + anyhow::bail!( + "directory `{}` does not contain a main.kcl file or a project.toml file", + input.display() + ); + } + } else if input.file_name().and_then(|name| name.to_str()) == Some("project.toml") { + input + .parent() + .context("project.toml is missing a parent directory")? + .to_path_buf() + } else if input.extension().and_then(|ext| ext.to_str()) == Some("kcl") { + if let Some(parent) = input.parent() { + if let Some(project_toml) = crate::cmd_kcl::find_project_toml(parent)? { + project_toml + .parent() + .context("project.toml is missing a parent directory")? + .to_path_buf() + } else { + parent.to_path_buf() + } + } else { + anyhow::bail!("could not determine project root from `{}`", input.display()); + } + } else { + anyhow::bail!( + "input `{}` must be a directory, a `.kcl` file, or a `project.toml` file", + input.display() + ); + }; + + if !root.join("main.kcl").exists() { + anyhow::bail!("project root `{}` does not contain a main.kcl file", root.display()); + } + + let project_toml = ensure_project_toml(&root)?; + + Ok(LocalProject { root, project_toml }) +} + +pub fn read_persisted_cloud_project_id(project_toml: &Path) -> Result> { + if !project_toml.exists() { + return Ok(None); + } + + let contents = std::fs::read_to_string(project_toml) + .with_context(|| format!("failed to read `{}`", project_toml.display()))?; + let doc = contents + .parse::() + .with_context(|| format!("failed to parse `{}`", project_toml.display()))?; + + let Some(project_id) = doc + .get(ZOO_TABLE_KEY) + .and_then(|item| item.get(ZOO_PROJECT_ID_KEY)) + .and_then(|item| item.as_str()) + else { + return Ok(None); + }; + + Ok(Some(uuid::Uuid::parse_str(project_id).with_context(|| { + format!( + "failed to parse `{}.{}` in `{}` as a UUID", + ZOO_TABLE_KEY, + ZOO_PROJECT_ID_KEY, + project_toml.display() + ) + })?)) +} + +pub fn persist_cloud_project_id(project_toml: &Path, id: uuid::Uuid) -> Result<()> { + let existing = if project_toml.exists() { + std::fs::read_to_string(project_toml).with_context(|| format!("failed to read `{}`", project_toml.display()))? + } else { + toml::to_string(&kcl_lib::ProjectConfiguration::default())? + }; + + let mut doc = existing + .parse::() + .with_context(|| format!("failed to parse `{}`", project_toml.display()))?; + + let has_zoo_table = matches!(doc.get(ZOO_TABLE_KEY), Some(item) if item.is_table()); + if !has_zoo_table { + doc.insert(ZOO_TABLE_KEY, toml_edit::Item::Table(toml_edit::Table::new())); + } + doc[ZOO_TABLE_KEY][ZOO_PROJECT_ID_KEY] = toml_edit::value(id.to_string()); + + std::fs::write(project_toml, doc.to_string()) + .with_context(|| format!("failed to write `{}`", project_toml.display()))?; + + Ok(()) +} + +pub fn collect_project_attachments(root: &Path) -> Result> { + let gitignore = project_gitignore(root)?; + let mut dirs = VecDeque::from([root.to_path_buf()]); + let mut files = Vec::new(); + + while let Some(dir) = dirs.pop_front() { + for entry in std::fs::read_dir(&dir).with_context(|| format!("failed to read `{}`", dir.display()))? { + let entry = entry.with_context(|| format!("failed to inspect entry in `{}`", dir.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("failed to inspect `{}`", entry.path().display()))?; + + if file_type.is_symlink() { + continue; + } + + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + + if file_type.is_dir() { + if should_skip_dir(&name) || is_ignored_by_project_gitignore(gitignore.as_ref(), &path, true) { + continue; + } + dirs.push_back(path); + continue; + } + + if file_type.is_file() && !is_ignored_by_project_gitignore(gitignore.as_ref(), &path, false) { + files.push(path); + } + } + } + + files.sort(); + + files.into_iter().map(|path| build_attachment(root, &path)).collect() +} + +pub fn find_project_root_under(base: &Path) -> Result> { + let mut dirs = VecDeque::from([base.to_path_buf()]); + let mut matches = Vec::new(); + + while let Some(dir) = dirs.pop_front() { + if dir.join("main.kcl").exists() { + matches.push(dir.clone()); + } + + for entry in std::fs::read_dir(&dir).with_context(|| format!("failed to read `{}`", dir.display()))? { + let entry = entry.with_context(|| format!("failed to inspect entry in `{}`", dir.display()))?; + let file_type = entry + .file_type() + .with_context(|| format!("failed to inspect `{}`", entry.path().display()))?; + if file_type.is_dir() && !file_type.is_symlink() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if should_skip_dir(&name) { + continue; + } + dirs.push_back(entry.path()); + } + } + } + + matches.sort(); + Ok(matches.into_iter().next()) +} + +pub fn ensure_download_destination(output_dir: &Path, force: bool) -> Result<()> { + if output_dir.exists() { + let metadata = + std::fs::metadata(output_dir).with_context(|| format!("failed to inspect `{}`", output_dir.display()))?; + if !metadata.is_dir() { + anyhow::bail!("download destination `{}` is not a directory", output_dir.display()); + } + + let mut entries = + std::fs::read_dir(output_dir).with_context(|| format!("failed to read `{}`", output_dir.display()))?; + if !force && entries.next().transpose()?.is_some() { + anyhow::bail!( + "download destination `{}` is not empty; pass `--force` to overwrite existing files", + output_dir.display() + ); + } + } else { + std::fs::create_dir_all(output_dir).with_context(|| format!("failed to create `{}`", output_dir.display()))?; + } + + Ok(()) +} + +fn project_gitignore(root: &Path) -> Result> { + let gitignore_path = root.join(".gitignore"); + if !gitignore_path.is_file() { + return Ok(None); + } + + let mut builder = ignore::gitignore::GitignoreBuilder::new(root); + builder.add(gitignore_path); + let gitignore = builder + .build() + .with_context(|| format!("failed to parse `{}`", root.join(".gitignore").display()))?; + Ok(Some(gitignore)) +} + +fn is_ignored_by_project_gitignore( + gitignore: Option<&ignore::gitignore::Gitignore>, + path: &Path, + is_dir: bool, +) -> bool { + gitignore + .map(|gitignore| gitignore.matched_path_or_any_parents(path, is_dir).is_ignore()) + .unwrap_or(false) +} + +fn build_attachment(root: &Path, path: &Path) -> Result { + let mut attachment = kittycad::types::multipart::Attachment::try_from(path.to_path_buf()) + .with_context(|| format!("failed to read `{}`", path.display()))?; + let relative = path + .strip_prefix(root) + .with_context(|| format!("failed to strip `{}` from `{}`", root.display(), path.display()))?; + + let relative = relative.to_path_buf(); + attachment.name = relative.to_string_lossy().to_string(); + attachment.filepath = Some(relative); + Ok(attachment) +} + +fn ensure_project_toml(root: &Path) -> Result { + let path = root.join("project.toml"); + if path.exists() { + return Ok(path); + } + + let contents = toml::to_string(&kcl_lib::ProjectConfiguration::default())?; + std::fs::write(&path, contents).with_context(|| format!("failed to create `{}`", path.display()))?; + Ok(path) +} + +fn normalize_input_path(input: &Path) -> Result { + if input == Path::new(".") { + Ok(std::env::current_dir()?) + } else { + Ok(input.to_path_buf()) + } +} + +fn should_skip_dir(name: &str) -> bool { + matches!(name, ".git" | ".jj" | "target" | "node_modules") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persist_cloud_project_id_round_trip() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + + let project = resolve_local_project(tmp.path()).expect("resolve project"); + let id = uuid::Uuid::new_v4(); + persist_cloud_project_id(&project.project_toml, id).expect("persist cloud project id"); + + let got = read_persisted_cloud_project_id(&project.project_toml).expect("read cloud project id"); + assert_eq!(got, Some(id)); + } + + #[test] + fn persist_cloud_project_id_does_not_overwrite_local_project_id() { + let tmp = tempfile::tempdir().expect("tempdir"); + let local_id = uuid::Uuid::new_v4(); + let cloud_id = uuid::Uuid::new_v4(); + std::fs::write( + tmp.path().join("project.toml"), + format!( + "[settings.meta]\nid = \"{local_id}\"\n\n[settings.app]\n\n[settings.modeling]\n\n[settings.text_editor]\n\n[settings.command_bar]\n" + ), + ) + .expect("write project.toml"); + + persist_cloud_project_id(&tmp.path().join("project.toml"), cloud_id).expect("persist cloud project id"); + + let contents = std::fs::read_to_string(tmp.path().join("project.toml")).expect("read project.toml"); + let parsed: kcl_lib::ProjectConfiguration = toml::from_str(&contents).expect("parse project config"); + assert_eq!(parsed.settings.meta.id, local_id); + + let got = read_persisted_cloud_project_id(&tmp.path().join("project.toml")).expect("read cloud project id"); + assert_eq!(got, Some(cloud_id)); + } + + #[test] + fn collect_project_attachments_uses_relative_paths() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("subdir")).expect("mkdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + std::fs::write(tmp.path().join("subdir/part.kcl"), "cube(2)\n").expect("write part"); + + let project = resolve_local_project(tmp.path()).expect("resolve project"); + let attachments = collect_project_attachments(&project.root).expect("collect attachments"); + + let mut paths = attachments + .iter() + .filter_map(|attachment| attachment.filepath.as_ref()) + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + paths.sort(); + + assert_eq!(paths, vec!["main.kcl", "project.toml", "subdir/part.kcl"]); + } + + #[test] + fn collect_project_attachments_respects_root_gitignore() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("ignored-dir")).expect("mkdir ignored-dir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + std::fs::write(tmp.path().join(".gitignore"), "ignored.kcl\nignored-dir/\n").expect("write gitignore"); + std::fs::write(tmp.path().join("ignored.kcl"), "cube(2)\n").expect("write ignored"); + std::fs::write(tmp.path().join("ignored-dir/part.kcl"), "cube(3)\n").expect("write ignored dir file"); + std::fs::write(tmp.path().join("kept.kcl"), "cube(4)\n").expect("write kept"); + + let project = resolve_local_project(tmp.path()).expect("resolve project"); + let attachments = collect_project_attachments(&project.root).expect("collect attachments"); + + let mut paths = attachments + .iter() + .filter_map(|attachment| attachment.filepath.as_ref()) + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + paths.sort(); + + assert_eq!(paths, vec![".gitignore", "kept.kcl", "main.kcl", "project.toml"]); + } + + #[test] + fn find_project_root_under_prefers_the_project_directory() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(tmp.path().join("downloaded-project/subdir")).expect("mkdir"); + std::fs::write(tmp.path().join("downloaded-project/main.kcl"), "cube(1)\n").expect("write main"); + + let found = find_project_root_under(tmp.path()).expect("find project root"); + assert_eq!(found, Some(tmp.path().join("downloaded-project"))); + } + + #[test] + fn ensure_download_destination_rejects_non_empty_dir_without_force() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + + let err = ensure_download_destination(tmp.path(), false).expect_err("should reject non-empty dir"); + assert!(err.to_string().contains("pass `--force`"), "unexpected error: {err:#}"); + } + + #[test] + fn ensure_download_destination_allows_non_empty_dir_with_force() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + + ensure_download_destination(tmp.path(), true).expect("should allow non-empty dir with force"); + } + + #[test] + fn ensure_download_destination_creates_missing_dir() { + let tmp = tempfile::tempdir().expect("tempdir"); + let output_dir = tmp.path().join("downloaded-project"); + + ensure_download_destination(&output_dir, false).expect("create missing output dir"); + + assert!(output_dir.is_dir()); + } +}