diff --git a/Cargo.lock b/Cargo.lock index ed8f26c9..ca13a515 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -524,6 +524,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -605,6 +614,12 @@ dependencies = [ "serde", ] +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + [[package]] name = "castaway" version = "0.2.4" @@ -1157,6 +1172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -2131,6 +2147,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbd780fe5cc30f81464441920d82ac8740e2e46b29a6fad543ddd075229ce37e" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "home" version = "0.5.11" @@ -2785,16 +2807,15 @@ dependencies = [ [[package]] name = "kcl-api" version = "0.2.167" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d0782f9f12d26f00ae5c0a28a670e4e60ae8ea8f7eb8fe2dc644edbffc06b7d" +source = "git+https://github.com/KittyCAD/modeling-app?rev=16dee6595e8585a4aeaddcde435cda2d85b489ed#16dee6595e8585a4aeaddcde435cda2d85b489ed" dependencies = [ "indexmap 2.14.0", - "kcl-error", + "kcl-error 0.2.167 (git+https://github.com/KittyCAD/modeling-app?rev=16dee6595e8585a4aeaddcde435cda2d85b489ed)", "kittycad-measurements", "kittycad-unit-conversion-derive", "parse-display 0.11.0", "parse-display-derive 0.11.0", - "schemars", + "schemars 0.8.22", "serde", "ts-rs", "uuid", @@ -2803,8 +2824,7 @@ dependencies = [ [[package]] name = "kcl-derive-docs" version = "0.2.167" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "570fc50f5925bf4b10014fe3403789bdb83da16406c59dccd72d40eccd546e13" +source = "git+https://github.com/KittyCAD/modeling-app?rev=16dee6595e8585a4aeaddcde435cda2d85b489ed#16dee6595e8585a4aeaddcde435cda2d85b489ed" dependencies = [ "proc-macro2", "quote", @@ -2818,7 +2838,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5df43f5977901ffe0036cca941f2e542f6d04f70889d8d95735188c0e16944c6" dependencies = [ "miette", - "schemars", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.18", + "ts-rs", +] + +[[package]] +name = "kcl-error" +version = "0.2.167" +source = "git+https://github.com/KittyCAD/modeling-app?rev=16dee6595e8585a4aeaddcde435cda2d85b489ed#16dee6595e8585a4aeaddcde435cda2d85b489ed" +dependencies = [ + "miette", + "schemars 0.8.22", "serde", "serde_json", "thiserror 2.0.18", @@ -2828,8 +2861,7 @@ dependencies = [ [[package]] name = "kcl-lib" version = "0.2.167" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8554618da616fdb606bf0777271c644aa3779482f8ee2a47bd921c1f18c7114" +source = "git+https://github.com/KittyCAD/modeling-app?rev=16dee6595e8585a4aeaddcde435cda2d85b489ed#16dee6595e8585a4aeaddcde435cda2d85b489ed" dependencies = [ "ahash", "anyhow", @@ -2859,7 +2891,7 @@ dependencies = [ "js-sys", "kcl-api", "kcl-derive-docs", - "kcl-error", + "kcl-error 0.2.167 (git+https://github.com/KittyCAD/modeling-app?rev=16dee6595e8585a4aeaddcde435cda2d85b489ed)", "kittycad", "kittycad-modeling-cmds", "lazy_static", @@ -2876,7 +2908,7 @@ dependencies = [ "rgba_simple", "rmp-serde", "ropey", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sha2 0.11.0", @@ -2905,8 +2937,7 @@ dependencies = [ [[package]] name = "kcl-test-server" version = "0.2.167" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efc65b9bf2c113192810400f572c4c79b6b51724e089274c9bcafa4287904a4c" +source = "git+https://github.com/KittyCAD/modeling-app?rev=16dee6595e8585a4aeaddcde435cda2d85b489ed#16dee6595e8585a4aeaddcde435cda2d85b489ed" dependencies = [ "anyhow", "hyper 0.14.32", @@ -2946,7 +2977,7 @@ dependencies = [ "reqwest-middleware", "reqwest-retry", "reqwest-tracing", - "schemars", + "schemars 0.8.22", "serde", "serde_bytes", "serde_json", @@ -2970,9 +3001,8 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds" -version = "0.2.208" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6e1f60a7440459009f291de2287e3c9b030120c3827ecb8a467a31370103d14" +version = "0.2.209" +source = "git+https://github.com/KittyCAD/modeling-api?rev=41eb579f216f8bd827fdb45b116de7719139597f#41eb579f216f8bd827fdb45b116de7719139597f" dependencies = [ "anyhow", "bon", @@ -2982,25 +3012,27 @@ dependencies = [ "enum-iterator-derive", "euler", "http 1.4.2", + "kcl-error 0.2.167 (registry+https://github.com/rust-lang/crates.io-index)", "kittycad-measurements", "kittycad-modeling-cmds-macros", "kittycad-unit-conversion-derive", "parse-display 0.11.0", "parse-display-derive 0.11.0", - "schemars", + "schemars 0.8.22", "serde", "serde_bytes", "serde_json", + "serde_with", "tabled", "ts-rs", + "typed-path", "uuid", ] [[package]] name = "kittycad-modeling-cmds-macros" version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4ba190f74f6d32f4607ecac4bdebd4a935d4943b8ca8369305e6e7a59b5976" +source = "git+https://github.com/KittyCAD/modeling-api?rev=41eb579f216f8bd827fdb45b116de7719139597f#41eb579f216f8bd827fdb45b116de7719139597f" dependencies = [ "kittycad-modeling-cmds-macros-impl", "proc-macro2", @@ -3011,8 +3043,7 @@ dependencies = [ [[package]] name = "kittycad-modeling-cmds-macros-impl" version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743b6533d1848de67e3455a28614a2b7896807ab0d3358ccd3ea1863de3c4e6b" +source = "git+https://github.com/KittyCAD/modeling-api?rev=41eb579f216f8bd827fdb45b116de7719139597f#41eb579f216f8bd827fdb45b116de7719139597f" dependencies = [ "proc-macro2", "quote", @@ -3652,7 +3683,7 @@ dependencies = [ "reqwest 0.12.28", "reqwest-middleware", "rustfmt-wrapper", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_yaml 0.9.34+deprecated", @@ -4540,6 +4571,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" version = "1.12.4" @@ -5001,6 +5052,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -5191,6 +5266,38 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_yaml" version = "0.8.26" @@ -7386,6 +7493,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "built 0.7.7", + "camino", "chrono", "clap", "clap_complete", @@ -7403,6 +7511,7 @@ dependencies = [ "ignore", "image", "itertools 0.15.0", + "kcl-error 0.2.167 (registry+https://github.com/rust-lang/crates.io-index)", "kcl-lib", "kcl-test-server", "kittycad", @@ -7421,6 +7530,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "ring", + "rmp-serde", "serde", "serde_json", "serde_yaml 0.9.34+deprecated", diff --git a/Cargo.toml b/Cargo.toml index 1bbb8d03..2f9f9934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = ["cli-macro", "cli-macro-impl"] anyhow = { version = "1", features = ["backtrace"] } async-trait = "0.1.88" base64 = "0.22.1" +camino = "1.2.4" chrono = { version = "0.4", default-features = false, features = ["serde"] } clap = { version = "4.6.1", features = [ "cargo", @@ -39,6 +40,7 @@ image = { version = "0.25", default-features = false, features = [ "jpeg", ] } itertools = "0.15.0" +kcl-error = "=0.2.167" kcl-lib = { version = "=0.2.167", features = ["disable-println"] } kcl-test-server = "=0.2.167" kittycad = { version = "0.4.12", features = [ @@ -47,7 +49,7 @@ kittycad = { version = "0.4.12", features = [ "requests", "retry", ] } -kittycad-modeling-cmds = { version = "0.2.208", features = [ +kittycad-modeling-cmds = { version = "0.2.209", features = [ "websocket", "tabled", ] } @@ -73,6 +75,7 @@ reqwest = { version = "0.12", default-features = false, features = [ "deflate", ] } ring = "0.17.14" +rmp-serde = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -121,5 +124,6 @@ incremental = true debug = 0 [patch.crates-io] -# kcl-lib = { git = "https://github.com/KittyCAD/modeling-app", branch = "main" } -# kittycad-modeling-cmds = { git = "https://github.com/KittyCAD/modeling-api", branch = "achalmers/remove-cruft"} +kcl-lib = { git = "https://github.com/KittyCAD/modeling-app", rev = "16dee6595e8585a4aeaddcde435cda2d85b489ed" } +kcl-test-server = { git = "https://github.com/KittyCAD/modeling-app", rev = "16dee6595e8585a4aeaddcde435cda2d85b489ed" } +kittycad-modeling-cmds = { git = "https://github.com/KittyCAD/modeling-api", rev = "41eb579f216f8bd827fdb45b116de7719139597f" } diff --git a/src/build_kcl_project.rs b/src/build_kcl_project.rs new file mode 100644 index 00000000..920560dc --- /dev/null +++ b/src/build_kcl_project.rs @@ -0,0 +1,232 @@ +//! This module reads a KCL project from disk into memory, so it can be sent over the network. +use std::env::current_dir; + +use anyhow::{Result, anyhow}; +use camino::{Utf8Path, Utf8PathBuf}; +use kittycad_modeling_cmds::{ + exec_kcl::{KclFile, KclProject}, + shared::safe_filepath::SafeFilepath, +}; + +const FILES_TO_SEND_TO_ENGINE: [&str; 18] = [ + "kcl", "fbx", "glb", "gltf", "obj", "ply", "step", "stl", "sat", "sab", "model", "catpart", "ipt", "prt", "xpr", + "x_t", "x_t", "sldprt", +]; + +/// If the user passed '.' or '-' then the KCL project's entrypoint +/// is assumed to be 'main.kcl'. +/// If filepath is '-' then the code should be given in `code`. Otherwise that argument is not used. +pub fn build_kcl_project(filepath: &Utf8Path, code: &str) -> Result { + let cwd = current_dir().map_err(|e| anyhow!("Could not get current working directory: {e}"))?; + let cwd = Utf8PathBuf::from_path_buf(cwd) + .map_err(|cwd| anyhow!("expected cwd to be unicode but found: {}", cwd.display()))?; + // Single-file case, where file comes from stdin. + if filepath == "-" { + let entrypoint = safe_filepath("main.kcl")?; + let file = KclFile::new(entrypoint.clone(), code.as_bytes().to_vec()); + return Ok(KclProject::new(vec![file], entrypoint)); + } + + let (project_root, entrypoint) = find_kcl_directory(&cwd, filepath); + + let entrypoint = entrypoint.strip_prefix(&project_root)?; + + build_kcl_project_from(entrypoint, &project_root) +} + +/// Returns the project root, and the entrypoint (i.e. first KCL file to start executing) +fn find_kcl_directory(cwd: &Utf8Path, filepath: &Utf8Path) -> (Utf8PathBuf, Utf8PathBuf) { + // Get the project's root directory and entrypoint. + if filepath == "." { + // If user passed '.', then assume the entrypoint is main.kcl + let root = cwd.to_path_buf(); + let mut entrypoint = root.clone(); + entrypoint.push("main.kcl"); + (root, entrypoint) + } else { + let root = filepath + .parent() + .map(|parent| parent.to_path_buf()) + .unwrap_or(Utf8PathBuf::from(".")); + if root == "" { + (Utf8PathBuf::from("."), Utf8PathBuf::from(format!("./{filepath}"))) + } else { + (root, filepath.to_owned()) + } + } +} + +fn build_kcl_project_from(entrypoint: &Utf8Path, project_root: &Utf8Path) -> Result { + // Find all relevant files in the KCL project. + let mut files = Vec::new(); + for entry in ignore::WalkBuilder::new(project_root).build() { + let entry = entry.map_err(|e| anyhow!("could not read entry: {e}"))?; + // We only care about files here, the Walker takes care of directories. + if !entry.file_type().is_some_and(|file_type| file_type.is_file()) { + continue; + } + let Some(extension) = entry.path().extension().map(|s| s.display().to_string()) else { + continue; + }; + if !FILES_TO_SEND_TO_ENGINE.contains(&extension.as_str()) { + continue; + } + + let contents = std::fs::read(entry.path())?; + + let path = Utf8Path::from_path(entry.path()) + .ok_or(anyhow!("invalid path {}", entry.path().display()))? + .to_path_buf(); + let relative_path = path.strip_prefix(project_root)?; + + files.push(KclFile::new(safe_filepath(relative_path.as_str())?, contents)); + } + + let entrypoint = safe_filepath(entrypoint.as_str())?; + Ok(KclProject::new(files, entrypoint)) +} + +fn safe_filepath(path: &str) -> Result { + path.parse::() + .map_err(|err| anyhow!("invalid KCL project path `{path}`: {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Changes working directory when acquired, + /// changes it back to original when dropped. + struct CurrentDirGuard { + original: std::path::PathBuf, + } + + impl CurrentDirGuard { + fn set_to(path: &std::path::Path) -> Self { + let original = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self { original } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.original).unwrap(); + } + } + + /// List files in the project, in alphabetical order. + fn project_file_paths(project: &KclProject) -> Vec { + let mut paths = project + .files + .iter() + .map(|file| file.path.to_string()) + .collect::>(); + paths.sort(); + paths + } + + /// Read this file from this project. Panics if file not found. + fn project_file_contents(project: &KclProject, path: &str) -> String { + let file = project + .files + .iter() + .find(|file| file.path.to_string() == path) + .unwrap_or_else(|| panic!("missing project file `{path}`")); + String::from_utf8(file.contents.clone()).unwrap() + } + + const MAIN_DOT_KCL: &str = "main.kcl"; + const EXAMPLE_FILE_CONTENTS: &str = "cube(1)\n"; + + /// Test building a KCL project from - i.e. reading main.kcl from stdin + #[test] + fn build_kcl_project_from_stdin_uses_main_kcl() { + let contents = "cube(1)\n"; + let project = build_kcl_project(Utf8Path::new("-"), contents).unwrap(); + + // Projects built from - should have one file, main.kcl. + assert_eq!(project.entrypoint.to_string(), MAIN_DOT_KCL); + assert_eq!(project_file_paths(&project), vec![MAIN_DOT_KCL]); + assert_eq!(project_file_contents(&project, MAIN_DOT_KCL), contents); + } + + /// Test that passing a single file as the KCL project will use that file properly. + #[test] + #[serial_test::serial] + fn build_kcl_project_from_bare_file_uses_file_as_entrypoint() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("cube.kcl"), EXAMPLE_FILE_CONTENTS).unwrap(); + let _guard = CurrentDirGuard::set_to(tmp.path()); + + let project = build_kcl_project(Utf8Path::new("cube.kcl"), "should_be_ignored()").unwrap(); + + // Only that file should be included. + assert_eq!(project.entrypoint.to_string(), "cube.kcl"); + assert_eq!(project_file_paths(&project), vec!["cube.kcl"]); + assert_eq!(project_file_contents(&project, "cube.kcl"), EXAMPLE_FILE_CONTENTS); + } + + /// Test that all KCL files get included when using . (i.e. the cwd) + #[test] + #[serial_test::serial] + fn build_kcl_project_from_dot_uses_main_kcl_entrypoint() { + let tmp = tempfile::tempdir().unwrap(); + // Put two files in the cwd. + std::fs::write(tmp.path().join(MAIN_DOT_KCL), EXAMPLE_FILE_CONTENTS).unwrap(); + std::fs::write(tmp.path().join("part.kcl"), "sphere(1)\n").unwrap(); + let _guard = CurrentDirGuard::set_to(tmp.path()); + + let project = build_kcl_project(Utf8Path::new("."), "cube(2)\n").unwrap(); + + // Both files should be in the project. + assert_eq!(project.entrypoint.to_string(), MAIN_DOT_KCL); + assert_eq!(project_file_paths(&project), vec![MAIN_DOT_KCL, "part.kcl"]); + } + + /// Test that when you give the project entrypoint as some file nested in + /// a directory, the files in that directory are all included. + #[test] + #[serial_test::serial] + fn build_kcl_project_from_nested_main_uses_paths_relative_to_project_root() { + // Write two files into the cube/ directory. + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("cube"); + std::fs::create_dir(&project_dir).unwrap(); + std::fs::write(project_dir.join(MAIN_DOT_KCL), "import \"part.kcl\"\n").unwrap(); + std::fs::write(project_dir.join("part.kcl"), "sphere(1)\n").unwrap(); + let _guard = CurrentDirGuard::set_to(tmp.path()); + + let project = build_kcl_project(Utf8Path::new("cube/main.kcl"), "cube(2)\n").unwrap(); + + // Directory name should be stripped, + // i.e. we should see main.kcl, not cube/main.kcl + assert_eq!(project.entrypoint.to_string(), MAIN_DOT_KCL); + assert_eq!(project_file_paths(&project), vec![MAIN_DOT_KCL, "part.kcl"]); + } + + /// Test that relevant files (e.g. kcl, step) are included, + /// but not irrelevant files (e.g. txt) + #[test] + #[serial_test::serial] + fn build_kcl_project_includes_supported_assets_and_skips_unrelated_files() { + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("assembly"); + std::fs::create_dir(&project_dir).unwrap(); + std::fs::write(project_dir.join(MAIN_DOT_KCL), "import \"part.kcl\"\n").unwrap(); + std::fs::write(project_dir.join("part.kcl"), "sphere(1)\n").unwrap(); + std::fs::write(project_dir.join("shape.step"), b"step bytes").unwrap(); + std::fs::write(project_dir.join("notes.txt"), "not sent\n").unwrap(); + std::fs::write(project_dir.join("no_extension"), "not sent\n").unwrap(); + let _guard = CurrentDirGuard::set_to(tmp.path()); + + let project = build_kcl_project(Utf8Path::new("assembly/main.kcl"), "cube(2)\n").unwrap(); + + assert_eq!(project.entrypoint.to_string(), MAIN_DOT_KCL); + assert_eq!( + project_file_paths(&project), + vec![MAIN_DOT_KCL, "part.kcl", "shape.step"] + ); + assert_eq!(project_file_contents(&project, "shape.step"), "step bytes"); + } +} diff --git a/src/cmd_kcl.rs b/src/cmd_kcl.rs index 4bc8e5ce..32e1f17e 100644 --- a/src/cmd_kcl.rs +++ b/src/cmd_kcl.rs @@ -157,6 +157,38 @@ impl crate::cmd::Command for CmdKclExport { .map_err(|err| kcl_error_fmt::into_miette_for_parse(&filepath.display().to_string(), &code, err))?; let meta_settings = program.meta_settings()?.unwrap_or_default(); let units: UnitLength = meta_settings.default_length_units.to_kcmc(); + let output_format = get_output_format(&self.output_format, units, self.deterministic); + + if crate::context::Context::use_server_kcl_execution() { + let (mut responses, session_data) = ctx + .run_server_kcl_then_modeling_cmds( + "", + &filepath, + &code, + vec![kcmc::ModelingCmd::Export( + kcmc::Export::builder().entity_ids(vec![]).format(output_format).build(), + )], + settings, + self.run_options.issue_check(), + ) + .await?; + let Some(kcmc::websocket::OkWebSocketResponseData::Export { files }) = responses.pop() else { + anyhow::bail!("Expected Export response from engine"); + }; + + for file in files { + let path = self.output_dir.join(file.name); + std::fs::write(&path, file.contents)?; + + writeln!(ctx.io.out, "Wrote file: {}", path.display())?; + } + + if self.show_trace { + print_trace_link(&mut ctx.io, &session_data) + } + + return Ok(()); + } let client = ctx.api_client("")?; let ectx = kcl_lib::ExecutorContext::new(&client, settings).await?; @@ -174,9 +206,7 @@ impl crate::cmd::Command for CmdKclExport { self.run_options.issue_check(), )?; - let files = ectx - .export(get_output_format(&self.output_format, units, self.deterministic)) - .await?; + let files = ectx.export(output_format).await?; // Save the files to our export directory. for file in files { @@ -506,11 +536,17 @@ impl crate::cmd::Command for CmdKclSnapshot { }; let output_file_display = self.output_file.display().to_string(); + let from_engine = if crate::context::Context::use_server_kcl_execution() { + "from engine " + } else { + "" + }; + // Is there just 1 PNG? match <[_; 1]>::try_from(many_pngs) { Ok([single]) => { std::fs::write(&self.output_file, single)?; - writeln!(ctx.io.out, "Snapshot saved to `{output_file_display}`")?; + writeln!(ctx.io.out, "Snapshot {from_engine}saved to `{output_file_display}`")?; } // If not, maybe there's 4 PNGs? Err(output_file_contents) => match <[_; 4]>::try_from(output_file_contents) { @@ -530,7 +566,7 @@ impl crate::cmd::Command for CmdKclSnapshot { &self.output_file, output_format, )?; - writeln!(ctx.io.out, "Snapshot saved to `{output_file_display}`")?; + writeln!(ctx.io.out, "Snapshot {from_engine}saved to `{output_file_display}`")?; } // If not 4, error. Err(vec) => { diff --git a/src/context.rs b/src/context.rs index fe03b9d6..34bc2122 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,14 +1,34 @@ -use std::{io::Write, str::FromStr, time::Duration}; +use std::{io::Write, path::Path, str::FromStr, time::Duration}; use anyhow::{Result, anyhow}; -use futures::StreamExt; +use camino::Utf8Path; +use futures::{SinkExt, StreamExt}; use kcl_lib::engine_connection::EngineManager; -use kcmc::{each_cmd as mcmd, websocket::OkWebSocketResponseData}; use kittycad::types::{ApiCallStatus, AsyncApiCallOutput, TextToCad, TextToCadCreateBody, TextToCadMultiFileIteration}; -use kittycad_modeling_cmds::{self as kcmc, ModelingCmd, output::TakeSnapshot, websocket::ModelingSessionData}; -use tokio_tungstenite::{WebSocketStream, tungstenite::protocol::Role}; - -use crate::{cmd_kcl, config::Config, config_file::get_env_var, kcl_error_fmt, types::FormatOutput}; +use kittycad_modeling_cmds::{ + ModelingCmd, each_cmd as mcmd, + output::TakeSnapshot, + websocket::{ + FailureWebSocketResponse, ModelingCmdReq, ModelingSessionData, OkWebSocketResponseData, + SuccessWebSocketResponse, WebSocketRequest, WebSocketResponse, + }, +}; +use tokio_tungstenite::{ + WebSocketStream, + tungstenite::{Message as WsMsg, protocol::Role}, +}; + +use crate::{ + build_kcl_project::build_kcl_project, cmd_kcl, config::Config, config_file::get_env_var, kcl_error_fmt, + types::FormatOutput, +}; + +type DirectWs = WebSocketStream; +type DirectWsRead = futures::stream::SplitStream; +type DirectWsWrite = futures::stream::SplitSink; + +const ENGINE_EXECUTION_ENV: &str = "ENGINE_EXECUTION"; +const WS_RESPONSE_TIMEOUT_SECS: u64 = 600; pub struct Context<'a> { pub config: &'a mut (dyn Config + Send + Sync + 'a), @@ -223,6 +243,168 @@ impl<'a> Context<'a> { Ok(engine) } + /// Should KCL be executed on the server (true)? + /// Or locally (false)? + pub(crate) fn use_server_kcl_execution() -> bool { + std::env::var(ENGINE_EXECUTION_ENV) + .map(|value| !value.is_empty()) + .unwrap_or_default() + } + + async fn engine_ws_with_settings( + &self, + hostname: &str, + settings: &kcl_lib::ExecutorSettings, + ) -> Result { + let client = self.api_client(hostname)?; + let pr = std::env::var("ZOO_ENGINE_PR").ok().and_then(|s| s.parse().ok()); + let (ws, _headers) = client + .modeling() + .commands_ws(kittycad::modeling::CommandsWsParams { + api_call_id: None, + fps: None, + order_independent_transparency: None, + pool: None, + post_effect: if settings.enable_ssao { + Some(kittycad::types::PostEffectType::Ssao) + } else { + None + }, + pr, + replay: settings.replay.clone(), + show_grid: if settings.show_grid { Some(true) } else { None }, + unlocked_framerate: None, + video_res_height: None, + video_res_width: None, + webrtc: Some(false), + }) + .await?; + Ok(ws) + } + + /// Run this KCL on the server, then send some followup modeling commands + /// (e.g. snapshots, exports, physics analysis) and report their results. + pub(crate) async fn run_server_kcl_then_modeling_cmds( + &mut self, + hostname: &str, + filepath: &Path, + code: &str, + cmds: Vec, + settings: kcl_lib::ExecutorSettings, + issue_check: kcl_error_fmt::KclIssueCheck, + ) -> Result<(Vec, Option)> { + let Some(filepath) = Utf8Path::from_path(filepath) else { + anyhow::bail!("Invalid filepath {} (must be unicode)", filepath.display()); + }; + let project = build_kcl_project(filepath, code)?; + let ws = self.engine_ws_with_settings(hostname, &settings).await?; + let wsconfig = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(usize::MAX)) + .max_frame_size(Some(usize::MAX)); + let ws_stream = WebSocketStream::from_raw_socket(ws, Role::Client, Some(wsconfig)).await; + let (mut write, mut read) = ws_stream.split(); + let mut session_data = None; + let mut heartbeat = + tokio::time::interval(Duration::from_secs(settings.heartbeats.unwrap_or(cmd_kcl::HEARTBEATS))); + + let exec_request_id = uuid::Uuid::new_v4(); + send_ws_request( + &mut write, + WebSocketRequest::ExecKclProject { + request_id: exec_request_id, + project, + }, + ) + .await?; + + // Handle engine responses, looking for KCL execution response. + loop { + let resp = read_ws_response_with_heartbeat(&mut read, &mut write, &mut heartbeat) + .await + .map_err(|e| anyhow!("During KCL execution, failed to communicate with engine: {e}"))?; + if let Some(session) = update_session_data(&resp) { + session_data = Some(session); + continue; + } + + let success_resp = match resp { + WebSocketResponse::Success(success) => success, + WebSocketResponse::Failure(FailureWebSocketResponse { errors, .. }) => { + if errors.is_empty() { + anyhow::bail!("Failed executing KCL on engine, but the engine returned no error details") + } else { + let all_errors = errors + .into_iter() + .map(|error| error.message) + .collect::>() + .join("\n"); + anyhow::bail!("Failed executing KCL on engine, errors: {}", all_errors) + } + } + }; + + if success_resp.request_id != Some(exec_request_id) { + continue; + } + + let OkWebSocketResponseData::ExecKclProject { result } = success_resp.resp else { + anyhow::bail!( + "Expected ExecKclProject response, but engine returned {:?}", + success_resp.resp + ) + }; + + match result { + Ok(_) => break, + Err(err) => { + check_server_compilation_issues(&mut self.io.err_out, &err.non_fatal, issue_check) + .map_err(|e| anyhow!("KCL execution had errors: {e}"))?; + if let Some(error) = err.error { + return Err(anyhow!("KCL execution failed: {}", error.get_message())); + } + break; + } + } + } + + // Send all follow-up commands, looking for each's response. + let mut responses = Vec::with_capacity(cmds.len()); + for cmd in cmds { + let cmd_id = uuid::Uuid::new_v4(); + send_ws_request( + &mut write, + WebSocketRequest::ModelingCmdReq(ModelingCmdReq { + cmd, + cmd_id: cmd_id.into(), + }), + ) + .await?; + + loop { + let resp = read_ws_response_with_heartbeat(&mut read, &mut write, &mut heartbeat).await?; + if let Some(session) = update_session_data(&resp) { + session_data = Some(session); + continue; + } + + if response_request_id(&resp) != Some(cmd_id) { + continue; + } + + match resp { + WebSocketResponse::Success(SuccessWebSocketResponse { resp, .. }) => { + responses.push(resp); + break; + } + WebSocketResponse::Failure(_) => return Err(websocket_failure_to_anyhow(resp)), + } + } + } + + let _ = write.send(WsMsg::Close(None)).await; + Ok((responses, session_data)) + } + pub async fn send_kcl_modeling_cmd( &mut self, hostname: &str, @@ -232,6 +414,32 @@ impl<'a> Context<'a> { settings: kcl_lib::ExecutorSettings, issue_check: kcl_error_fmt::KclIssueCheck, ) -> Result<(OkWebSocketResponseData, Option)> { + if Self::use_server_kcl_execution() { + let (mut responses, session_data) = self + .run_server_kcl_then_modeling_cmds( + hostname, + Path::new(filename), + code, + vec![ + ModelingCmd::from( + mcmd::ZoomToFit::builder() + .animated(false) + .object_ids(Default::default()) + .padding(0.1) + .build(), + ), + cmd, + ], + settings, + issue_check, + ) + .await?; + let resp = responses + .pop() + .ok_or_else(|| anyhow!("Expected response from engine after executing KCL"))?; + return Ok((resp, session_data)); + } + let client = self.api_client(hostname)?; let program = kcl_lib::Program::parse_no_errs(code) @@ -286,6 +494,12 @@ impl<'a> Context<'a> { settings: kcl_lib::ExecutorSettings, issue_check: kcl_error_fmt::KclIssueCheck, ) -> Result<(Vec, Option)> { + if Self::use_server_kcl_execution() { + return self + .run_server_kcl_then_modeling_cmds(hostname, Path::new(filename), code, cmds, settings, issue_check) + .await; + } + let client = self.api_client(hostname)?; let program = kcl_lib::Program::parse_no_errs(code) @@ -330,6 +544,30 @@ impl<'a> Context<'a> { settings: kcl_lib::ExecutorSettings, issue_check: kcl_error_fmt::KclIssueCheck, ) -> Result<(Vec, Option)> { + if Self::use_server_kcl_execution() { + let (responses, session_data) = self + .run_server_kcl_then_modeling_cmds( + hostname, + Path::new(filename), + code, + snapshot_cmds, + settings, + issue_check, + ) + .await?; + let mut snapshot_resps = Vec::new(); + for resp in responses { + if let OkWebSocketResponseData::Modeling { + modeling_response: kittycad_modeling_cmds::ok_response::OkModelingCmdResponse::TakeSnapshot(snap), + } = resp + { + snapshot_resps.push(snap); + } + } + + return Ok((snapshot_resps, session_data)); + } + let client = self.api_client(hostname)?; let program = kcl_lib::Program::parse_no_errs(code) @@ -722,7 +960,7 @@ impl<'a> Context<'a> { let path_display = file.display().to_string(); let contents = tokio::fs::read(&file) .await - .map_err(|err| anyhow::anyhow!("Failed to read file `{path_display}`: {err:?}"))?; + .map_err(|err| anyhow!("Failed to read file `{path_display}`: {err:?}"))?; Ok::( kittycad::types::multipart::Attachment { @@ -1021,6 +1259,108 @@ pub(crate) fn reasoning_to_markdown(reason: &kittycad::types::ReasoningMessage) } } +fn check_server_compilation_issues( + err_out: &mut impl std::io::Write, + issues: &[kcl_error::CompilationIssue], + issue_check: kcl_error_fmt::KclIssueCheck, +) -> Result<()> { + if issue_check == kcl_error_fmt::KclIssueCheck::Ignore || issues.is_empty() { + return Ok(()); + } + + for issue in issues { + writeln!(err_out, "{:?}: {}", issue.severity, issue.message)?; + } + + if issue_check == kcl_error_fmt::KclIssueCheck::DenyErrors && issues.iter().any(|issue| issue.is_err()) { + anyhow::bail!( + "KCL execution reported errors. Please fix your KCL program before continuing. If you really want to proceed anyway, rerun this command with `--allow-errors`." + ); + } + + Ok(()) +} + +async fn send_ws_request(write: &mut DirectWsWrite, request: WebSocketRequest) -> Result<()> { + let msg = serde_json::to_string(&request)?; + write + .send(WsMsg::Text(msg.into())) + .await + .map_err(|err| anyhow!("could not send request to engine websocket: {err}"))?; + Ok(()) +} + +async fn read_ws_response_with_heartbeat( + read: &mut DirectWsRead, + write: &mut DirectWsWrite, + heartbeat: &mut tokio::time::Interval, +) -> Result { + let timeout = tokio::time::sleep(Duration::from_secs(WS_RESPONSE_TIMEOUT_SECS)); + tokio::pin!(timeout); + + loop { + tokio::select! { + maybe_msg = read.next() => { + let Some(msg) = maybe_msg else { + anyhow::bail!("engine websocket closed before sending a response"); + }; + return parse_ws_msg(msg?); + } + _ = heartbeat.tick() => { + send_ws_request(write, WebSocketRequest::Ping {}).await?; + } + _ = &mut timeout => { + anyhow::bail!("engine websocket response timed out after {WS_RESPONSE_TIMEOUT_SECS}s"); + } + } + } +} + +fn parse_ws_msg(msg: WsMsg) -> Result { + match msg { + WsMsg::Text(text) => Ok(serde_json::from_str(&text)?), + WsMsg::Binary(bin) => Ok(rmp_serde::from_slice(&bin)?), + other => anyhow::bail!("unexpected engine websocket message: {other}"), + } +} + +fn update_session_data(response: &WebSocketResponse) -> Option { + match response { + WebSocketResponse::Success(SuccessWebSocketResponse { + resp: OkWebSocketResponseData::ModelingSessionData { session }, + .. + }) => Some(session.clone()), + _ => None, + } +} + +fn response_request_id(response: &WebSocketResponse) -> Option { + match response { + WebSocketResponse::Success(SuccessWebSocketResponse { request_id, .. }) => *request_id, + WebSocketResponse::Failure(FailureWebSocketResponse { request_id, .. }) => *request_id, + } +} + +fn websocket_failure_to_anyhow(response: WebSocketResponse) -> anyhow::Error { + match response { + WebSocketResponse::Failure(FailureWebSocketResponse { errors, .. }) => { + if errors.is_empty() { + anyhow!("engine websocket request failed with no error details") + } else { + anyhow!( + "{}", + errors + .into_iter() + .map(|error| error.message) + .collect::>() + .join("\n") + ) + } + } + other => anyhow!("unexpected engine websocket response: {other:?}"), + } +} + fn indent_block(s: &str) -> String { let mut out = String::new(); for line in s.lines() { diff --git a/src/main.rs b/src/main.rs index 4db767af..da2d8751 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ // Always export the cmd_* modules as public so that it tells us when we are // missing docs. +mod build_kcl_project; mod cmd; /// The alias command. pub mod cmd_alias; diff --git a/src/tests.rs b/src/tests.rs index aee0566a..ee8681a9 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -36,6 +36,7 @@ struct TestItem { want_code: i32, current_directory: Option, setup: Option, + env: Vec<(String, String)>, } impl TestItem { @@ -49,9 +50,15 @@ impl TestItem { want_code: 0, current_directory: None, setup: None, + env: Default::default(), } } + fn set_env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } + fn stdin(mut self, stdin: impl Into) -> Self { self.stdin = Some(stdin.into()); self @@ -289,6 +296,13 @@ async fn run_test_item_with_config(config: &mut TestConfig, item: TestItem) { io.stdin = Box::new(std::io::Cursor::new(stdin)); } + for (key, value) in &item.env { + // SAFETY: Tests run in serial. + unsafe { + std::env::set_var(key, value); + } + } + let mut command_ctx = crate::context::Context { config, io, @@ -743,6 +757,16 @@ cli_tests! { .stderr_contains("Prefer to use explicit units for angles") } + snapshot_via_engine(_ctx) => { + TestItem::new( + "run KCL on engine and snapshot", + svec!["zoo", "kcl", "snapshot", "tests/gear.kcl", "tests/gear.png"], + ) + .setup(setup_authenticated) + .stdout_contains("Snapshot from engine saved to `tests/gear.png`") + .set_env("ENGINE_EXECUTION", "1") + } + snapshot_a_kcl_file_with_a_project_toml_as_png(_ctx) => { TestItem::new( "snapshot a kcl file with a project.toml as png",