diff --git a/Cargo.lock b/Cargo.lock index 00fe66f7..6239d6d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1543,6 +1543,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" @@ -3073,6 +3084,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall 0.5.11", ] [[package]] @@ -5667,6 +5679,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" @@ -7161,6 +7184,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" @@ -7336,6 +7369,7 @@ dependencies = [ "git_rev", "heck", "http 1.4.0", + "ignore", "image", "itertools", "kcl-lib", @@ -7369,6 +7403,7 @@ dependencies = [ "slog-term", "tabled", "tabwriter", + "tar", "tempfile", "terminal_size", "test-context", diff --git a/Cargo.toml b/Cargo.toml index e673c7e6..abac2429 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ 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", @@ -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.4" thiserror = "2" tokio = { version = "1", features = ["full"] } @@ -119,4 +121,3 @@ debug = 0 [patch.crates-io] # 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_kcl.rs b/src/cmd_kcl.rs index 8870702c..2a60fde1 100644 --- a/src/cmd_kcl.rs +++ b/src/cmd_kcl.rs @@ -1722,8 +1722,7 @@ fn get_modeling_settings_from_project_toml(input: &std::path::Path) -> Result Result<()> { + match &self.subcmd { + SubCommand::Categories(cmd) => cmd.run(ctx).await, + SubCommand::Delete(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, + } + } +} + +/// 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.projects().list_categories().await?; + let categories = categories + .into_iter() + .map(project_category_output_row) + .collect::>(); + let format = ctx.format(&self.format)?; + ctx.io.write_output_for_vec(&format, categories)?; + Ok(()) + } +} + +/// Delete one of your uploaded projects. +#[derive(Parser, Debug, Clone)] +#[clap(verbatim_doc_comment)] +pub struct CmdProjectDelete { + /// The project id, or a local project directory, `.kcl` file, or `project.toml`. + /// + /// When a local path is provided, the persisted Zoo cloud project id will be removed from + /// `project.toml` after the remote project is deleted. + #[clap(name = "id-or-path", required = true)] + pub input: String, +} + +enum ProjectTarget { + Id(uuid::Uuid), + Local { + local: crate::project::LocalProject, + id: uuid::Uuid, + }, +} + +impl ProjectTarget { + fn id(&self) -> uuid::Uuid { + match self { + Self::Id(id) => *id, + Self::Local { id, .. } => *id, + } + } +} + +fn resolve_project_target(input: &str, environment: &str) -> Result { + let path = PathBuf::from(input); + if input == "." || path.exists() { + let local = crate::project::resolve_local_project(&path)?; + let id = crate::project::read_persisted_cloud_project_id(&local.project_toml, environment)? + .with_context(|| format!("no Zoo cloud project id found in `{}`", local.project_toml.display()))?; + return Ok(ProjectTarget::Local { local, id }); + } + + if let Ok(id) = uuid::Uuid::parse_str(input) { + return Ok(ProjectTarget::Id(id)); + } + + anyhow::bail!("input `{input}` must be an existing project path or a project id"); +} + +#[async_trait::async_trait(?Send)] +impl crate::cmd::Command for CmdProjectDelete { + async fn run(&self, ctx: &mut crate::context::Context) -> Result<()> { + let environment = ctx.project_cloud_environment_name("")?; + let target = resolve_project_target(&self.input, &environment)?; + let id = target.id(); + + let client = ctx.api_client("")?; + client.projects().delete(id).await?; + + if let ProjectTarget::Local { local, .. } = target { + crate::project::clear_persisted_cloud_project_id(&local.project_toml, &environment)?; + writeln!( + ctx.io.out, + "{} Deleted Zoo cloud project {} and cleared {}", + ctx.io.color_scheme().success_icon(), + id, + local.project_toml.display() + )?; + } else { + writeln!( + ctx.io.out, + "{} Deleted Zoo cloud project {}", + ctx.io.color_scheme().success_icon(), + id + )?; + } + + 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 environment = ctx.project_cloud_environment_name("")?; + + let endpoint = format!("/user/projects/{}/download", self.id); + let resp = ctx + .raw_http_request("", reqwest::Method::GET, &endpoint)? + .header(reqwest::header::ACCEPT, PROJECT_ARCHIVE_ACCEPT) + .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 project_root = extract_project_archive(body.as_ref(), &self.output_dir)?; + let project_toml = project_root.join("project.toml"); + crate::project::persist_cloud_project_id(&project_toml, &environment, self.id)?; + writeln!( + ctx.io.out, + "{} Downloaded project {} into {}", + ctx.io.color_scheme().success_icon(), + self.id, + project_root.display() + )?; + + Ok(()) + } +} + +fn extract_project_archive(archive_bytes: &[u8], output_dir: &Path) -> Result { + if archive_bytes.is_empty() { + anyhow::bail!("downloaded project archive was empty"); + } + + let mut archive = tar::Archive::new(std::io::Cursor::new(archive_bytes)); + archive + .unpack(output_dir) + .with_context(|| format!("failed to extract archive into `{}`", output_dir.display()))?; + + crate::project::find_project_root_under(output_dir)?.with_context(|| { + format!( + "downloaded project archive did not contain a project root under `{}`", + output_dir.display() + ) + }) +} + +/// 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 ProjectCategoryOutputRow { + description: String, + display_name: String, + slug: String, +} + +#[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_category_output_row(category: kittycad::types::ProjectCategoryResponse) -> ProjectCategoryOutputRow { + ProjectCategoryOutputRow { + description: category.description, + display_name: category.display_name, + slug: category.slug, + } +} + +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.projects().list().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, or a local project directory, `.kcl` file, or `project.toml`. + #[clap(name = "id-or-path", required = true)] + pub input: String, + + /// 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 environment = ctx.project_cloud_environment_name("")?; + let target = resolve_project_target(&self.input, &environment)?; + let project_id = target.id(); + let client = ctx.api_client("")?; + let project = client.projects().get(project_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 id, or a local project directory, `.kcl` file, or `project.toml`. + #[clap(name = "id-or-path", required = true)] + pub input: String, + + /// 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 environment = ctx.project_cloud_environment_name("")?; + let target = resolve_project_target(&self.input, &environment)?; + let project_id = target.id(); + + let client = ctx.api_client("")?; + let project = client.projects().publish(project_id).await?; + + if let ProjectTarget::Local { local, .. } = target { + crate::project::persist_cloud_project_id(&local.project_toml, &environment, 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", conflicts_with = "id")] + pub new: bool, + + /// Override the persisted Zoo cloud project id from `project.toml`. + #[clap(long, conflicts_with = "new")] + pub id: Option, + + /// 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 environment = ctx.project_cloud_environment_name("")?; + let existing_id = match self.id { + Some(id) => Some(id), + None if self.new => None, + None => crate::project::read_persisted_cloud_project_id(&local.project_toml, &environment)?, + }; + 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.projects().get(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, &environment, 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(()) + } +} + +#[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}")) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn build_project_archive(files: &[(&str, &str)]) -> Vec { + let mut bytes = Vec::new(); + let mut builder = tar::Builder::new(&mut bytes); + + for (path, contents) in files { + let mut header = tar::Header::new_gnu(); + header.set_path(path).expect("set path"); + header.set_mode(0o644); + header.set_size(contents.len() as u64); + header.set_cksum(); + builder.append(&header, contents.as_bytes()).expect("append file"); + } + + builder.finish().expect("finish archive"); + drop(builder); + bytes + } + + #[test] + fn resolve_project_target_accepts_uuid() { + let id = uuid::Uuid::new_v4(); + + let target = resolve_project_target(&id.to_string(), "zoo.dev").expect("resolve project target"); + + match target { + ProjectTarget::Id(got) => assert_eq!(got, id), + ProjectTarget::Local { .. } => panic!("expected uuid target"), + } + } + + #[test] + fn resolve_project_target_accepts_project_path() { + let tmp = tempfile::tempdir().expect("tempdir"); + std::fs::write(tmp.path().join("main.kcl"), "cube(1)\n").expect("write main"); + let project_toml = tmp.path().join("project.toml"); + let id = uuid::Uuid::new_v4(); + crate::project::persist_cloud_project_id(&project_toml, "zoo.dev", id).expect("persist cloud project id"); + + let target = + resolve_project_target(tmp.path().to_str().expect("path utf8"), "zoo.dev").expect("resolve project target"); + + match target { + ProjectTarget::Local { local, id: got } => { + assert_eq!(got, id); + assert_eq!(local.root, PathBuf::from(tmp.path())); + assert_eq!(local.project_toml, project_toml); + } + ProjectTarget::Id(_) => panic!("expected local target"), + } + } + + #[test] + fn extract_project_archive_rejects_empty_archive() { + let tmp = tempfile::tempdir().expect("tempdir"); + + let err = extract_project_archive(&[], tmp.path()).expect_err("empty archive should fail"); + + assert!( + err.to_string().contains("archive was empty"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn extract_project_archive_returns_project_root() { + let tmp = tempfile::tempdir().expect("tempdir"); + let archive = build_project_archive(&[ + ("downloaded-project/main.kcl", "cube(1)\n"), + ("downloaded-project/project.toml", ""), + ("downloaded-project/readme.md", "hello\n"), + ]); + + let project_root = extract_project_archive(&archive, tmp.path()).expect("extract project archive"); + + assert_eq!(project_root, tmp.path().join("downloaded-project")); + assert!(project_root.join("main.kcl").is_file()); + } +} diff --git a/src/cmd_user.rs b/src/cmd_user.rs index 3dd4f665..f9dd78dd 100644 --- a/src/cmd_user.rs +++ b/src/cmd_user.rs @@ -92,8 +92,8 @@ mod test { new_is_onboarded: Default::default(), new_company: Default::default(), new_discord: Default::default(), - new_phone: Default::default(), new_username: Default::default(), + new_phone: Default::default(), new_last_name: Default::default(), new_first_name: Default::default(), new_github: Default::default(), diff --git a/src/context.rs b/src/context.rs index 0b45bf9c..be5d32ae 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) - // 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) + 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)) .tcp_keepalive(std::time::Duration::from_secs(600)) .http1_only(); @@ -105,11 +110,37 @@ 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() } + pub fn project_cloud_environment_name(&self, hostname: &str) -> Result { + let (_, baseurl) = self.resolve_api_host_and_baseurl(hostname)?; + crate::project::project_cloud_environment_name_for_host(&baseurl) + } + // Test-only helper for verifying host resolution semantics without creating a client. #[cfg(test)] pub(crate) fn resolve_host_for_tests(&self, hostname: &str) -> Result { 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/ml/copilot/run.rs b/src/ml/copilot/run.rs index db4ffe3c..930d204d 100644 --- a/src/ml/copilot/run.rs +++ b/src/ml/copilot/run.rs @@ -681,8 +681,7 @@ fn get_modeling_settings_from_project_toml(input: &std::path::Path) -> anyhow::R input.parent().unwrap().to_path_buf() }; if let Some(p) = crate::cmd_kcl::find_project_toml(&dir)? { - let s = std::fs::read_to_string(&p)?; - let project: kcl_lib::ProjectConfiguration = toml::from_str(&s)?; + let project = crate::project::read_project_configuration(&p)?; let mut derived: kcl_lib::ExecutorSettings = project.into(); let typed = TypedPath::from(input.display().to_string().as_str()); derived.with_current_file(typed); diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 00000000..ce145ada --- /dev/null +++ b/src/project.rs @@ -0,0 +1,484 @@ +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, +}; + +use anyhow::{Context as _, Result}; + +pub struct LocalProject { + pub root: PathBuf, + pub project_toml: PathBuf, +} + +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, environment: &str) -> Result> { + if !project_toml.exists() { + return Ok(None); + } + + let config = read_project_configuration(project_toml)?; + let project_id = config + .cloud + .environments + .get(environment) + .map(|settings| settings.project_id); + + if let Some(project_id) = project_id { + return if project_id.is_nil() { + Ok(None) + } else { + Ok(Some(project_id)) + }; + } + + Ok(None) +} + +pub fn persist_cloud_project_id(project_toml: &Path, environment: &str, id: uuid::Uuid) -> Result<()> { + let mut config = read_or_default_project_configuration(project_toml)?; + config + .cloud + .environments + .entry(environment.to_owned()) + .or_default() + .project_id = id; + write_project_configuration(project_toml, &config) +} + +pub fn clear_persisted_cloud_project_id(project_toml: &Path, environment: &str) -> Result<()> { + let mut config = read_or_default_project_configuration(project_toml)?; + config.cloud.environments.shift_remove(environment); + write_project_configuration(project_toml, &config) +} + +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)) +} + +pub fn read_project_configuration(project_toml: &Path) -> Result { + let contents = std::fs::read_to_string(project_toml) + .with_context(|| format!("failed to read `{}`", project_toml.display()))?; + kcl_lib::ProjectConfiguration::parse_and_validate(&contents) + .with_context(|| format!("failed to parse `{}`", project_toml.display())) +} + +fn read_or_default_project_configuration(project_toml: &Path) -> Result { + if project_toml.exists() { + read_project_configuration(project_toml) + } else { + Ok(kcl_lib::ProjectConfiguration::default()) + } +} + +fn write_project_configuration(project_toml: &Path, config: &kcl_lib::ProjectConfiguration) -> Result<()> { + let contents = toml::to_string(config)?; + std::fs::write(project_toml, contents).with_context(|| format!("failed to write `{}`", project_toml.display()))?; + Ok(()) +} + +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()) + } +} + +pub fn project_cloud_environment_name_for_host(host: &str) -> Result { + let parsed = crate::cmd_auth::parse_host(host)?; + let hostname = parsed + .host_str() + .with_context(|| format!("host `{host}` is missing a hostname"))?; + + let mut environment = hostname.strip_prefix("api.").unwrap_or(hostname).to_string(); + if let Some(port) = parsed.port() { + environment.push(':'); + environment.push_str(&port.to_string()); + } + + Ok(environment) +} + +fn should_skip_dir(name: &str) -> bool { + matches!(name, ".git" | ".jj" | "target" | "node_modules") +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEFAULT_ENVIRONMENT: &str = "zoo.dev"; + + #[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, DEFAULT_ENVIRONMENT, id).expect("persist cloud project id"); + + let got = + read_persisted_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT).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"), DEFAULT_ENVIRONMENT, 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); + assert_eq!( + parsed + .cloud + .environments + .get(DEFAULT_ENVIRONMENT) + .expect("cloud environment") + .project_id, + cloud_id + ); + + let got = read_persisted_cloud_project_id(&tmp.path().join("project.toml"), DEFAULT_ENVIRONMENT) + .expect("read cloud project id"); + assert_eq!(got, Some(cloud_id)); + } + + #[test] + fn clear_persisted_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"); + persist_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT, uuid::Uuid::new_v4()) + .expect("persist cloud project id"); + + clear_persisted_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT).expect("clear cloud project id"); + + let got = + read_persisted_cloud_project_id(&project.project_toml, DEFAULT_ENVIRONMENT).expect("read cloud project id"); + assert_eq!(got, None); + } + + #[test] + fn clear_persisted_cloud_project_id_only_clears_requested_environment() { + let tmp = tempfile::tempdir().expect("tempdir"); + let zoo_id = uuid::Uuid::new_v4(); + let dev_id = uuid::Uuid::new_v4(); + std::fs::write( + tmp.path().join("project.toml"), + format!( + "[cloud.\"zoo.dev\"]\nproject_id = \"{zoo_id}\"\n\n[cloud.\"dev.zoo.dev\"]\nproject_id = \"{dev_id}\"\n" + ), + ) + .expect("write project.toml"); + + clear_persisted_cloud_project_id(&tmp.path().join("project.toml"), DEFAULT_ENVIRONMENT) + .expect("clear cloud project id"); + + assert_eq!( + read_persisted_cloud_project_id(&tmp.path().join("project.toml"), DEFAULT_ENVIRONMENT) + .expect("read zoo cloud project id"), + None + ); + assert_eq!( + read_persisted_cloud_project_id(&tmp.path().join("project.toml"), "dev.zoo.dev") + .expect("read dev cloud project id"), + Some(dev_id) + ); + } + + #[test] + fn project_cloud_environment_name_for_host_strips_api_prefix() { + assert_eq!( + project_cloud_environment_name_for_host("https://api.zoo.dev").expect("default environment"), + "zoo.dev" + ); + assert_eq!( + project_cloud_environment_name_for_host("https://api.dev.zoo.dev").expect("dev environment"), + "dev.zoo.dev" + ); + assert_eq!( + project_cloud_environment_name_for_host("http://localhost:8888").expect("localhost environment"), + "localhost:8888" + ); + } + + #[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()); + } +}