From c0daaa6cbc854d93859f671a89429042d3e08aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Mon, 17 Aug 2026 14:45:49 -0300 Subject: [PATCH 1/3] feat: add Flutter-aware Pickforge doctor --- .github/workflows/ci.yml | 19 + .gitignore | 1 + Cargo.lock | 636 +++++++++++++++++++++++++++ Cargo.toml | 9 + crates/pickforge-cli/Cargo.toml | 29 ++ crates/pickforge-cli/src/doctor.rs | 202 +++++++++ crates/pickforge-cli/src/env.rs | 59 +++ crates/pickforge-cli/src/lib.rs | 16 + crates/pickforge-cli/src/main.rs | 55 +++ crates/pickforge-cli/src/project.rs | 128 ++++++ crates/pickforge-cli/src/render.rs | 57 +++ crates/pickforge-cli/src/report.rs | 92 ++++ crates/pickforge-cli/src/state.rs | 42 ++ crates/pickforge-cli/src/tools.rs | 17 + crates/pickforge-cli/tests/cli.rs | 124 ++++++ crates/pickforge-cli/tests/doctor.rs | 438 ++++++++++++++++++ docs/releases/UNRELEASED.md | 25 +- 17 files changed, 1947 insertions(+), 2 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/pickforge-cli/Cargo.toml create mode 100644 crates/pickforge-cli/src/doctor.rs create mode 100644 crates/pickforge-cli/src/env.rs create mode 100644 crates/pickforge-cli/src/lib.rs create mode 100644 crates/pickforge-cli/src/main.rs create mode 100644 crates/pickforge-cli/src/project.rs create mode 100644 crates/pickforge-cli/src/render.rs create mode 100644 crates/pickforge-cli/src/report.rs create mode 100644 crates/pickforge-cli/src/state.rs create mode 100644 crates/pickforge-cli/src/tools.rs create mode 100644 crates/pickforge-cli/tests/cli.rs create mode 100644 crates/pickforge-cli/tests/doctor.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c095a..700ef8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,25 @@ jobs: ghcr.io/gitleaks/gitleaks:v8.30.1 \ git --redact --no-banner /repo + # Independent of the Bun job: the experimental `pickforge` Rust binary has no + # shared toolchain or artifacts with the TypeScript packages. + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - run: cargo fmt --check + + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - run: cargo test --workspace --locked + test: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 9f9b7c1..8f25ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ packages/cli/README.md packages/cli/LICENSE packages/cli/*.tgz +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..acff39c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,636 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pickforge-cli" +version = "0.0.0" +dependencies = [ + "assert_cmd", + "clap", + "directories", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "tempfile", + "thiserror", + "which", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d8b67fa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["crates/pickforge-cli"] +resolver = "2" + +[workspace.package] +version = "0.0.0" +edition = "2021" +license = "MIT" +publish = false diff --git a/crates/pickforge-cli/Cargo.toml b/crates/pickforge-cli/Cargo.toml new file mode 100644 index 0000000..0cd4c8b --- /dev/null +++ b/crates/pickforge-cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pickforge-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +description = "Experimental Pickforge CLI: project readiness diagnostics" + +[[bin]] +name = "pickforge" +path = "src/main.rs" + +[lib] +name = "pickforge_cli" +path = "src/lib.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +directories = "6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml_ng = "0.10" +sha2 = "0.10" +thiserror = "2" +which = "8" + +[dev-dependencies] +assert_cmd = "2" +tempfile = "3" diff --git a/crates/pickforge-cli/src/doctor.rs b/crates/pickforge-cli/src/doctor.rs new file mode 100644 index 0000000..259337a --- /dev/null +++ b/crates/pickforge-cli/src/doctor.rs @@ -0,0 +1,202 @@ +//! The `doctor` diagnostics: does this project directory look ready for the +//! next integration-pack step? +//! +//! Read-only by construction: nothing here writes, creates directories, runs +//! the discovered tools, or touches the network. + +use std::path::Path; + +use crate::env::Environment; +use crate::project::{canonical_project_path, derive_project_id, detect_flutter, FrameworkError}; +use crate::report::{Check, CheckStatus, DoctorReport, ProjectInfo}; +use crate::state::{project_state_dir, state_root, StateError, HOME_ENV_VAR}; + +/// Required build tools, in report order. +const REQUIRED_TOOLS: [&str; 2] = ["flutter", "dart"]; +/// Supported agent harnesses, in report order. At least one must exist. +const HARNESSES: [&str; 3] = ["claude", "codex", "pi"]; + +/// Diagnose `project_dir` against `env`, returning a fully resolved report. +/// Never panics and never mutates anything. +pub fn diagnose(project_dir: &Path, env: &Environment) -> DoctorReport { + let canonical = canonical_project_path(project_dir); + let project_id = derive_project_id(&canonical); + let mut checks = Vec::new(); + + let metadata = std::fs::metadata(&canonical); + let is_directory = metadata.as_ref().map(|meta| meta.is_dir()).unwrap_or(false); + checks.push(directory_check( + &canonical, + metadata.as_ref().err(), + is_directory, + )); + + let framework = if is_directory { + detect_flutter(&canonical) + } else { + Err(FrameworkError::PubspecMissing) + }; + checks.push(framework_check(&canonical, &framework)); + + for tool in REQUIRED_TOOLS { + checks.push(required_tool_check(env, tool)); + } + let mut found_harnesses = Vec::new(); + for harness in HARNESSES { + let found = crate::tools::find_on_path(env, harness); + if found.is_some() { + found_harnesses.push(harness); + } + checks.push(harness_check(harness, found.as_deref())); + } + checks.push(harness_aggregate_check(&found_harnesses)); + + let root = state_root(env); + checks.push(state_check(&root, &project_id)); + + let state_dir = root.as_ref().ok().map(|root| { + project_state_dir(root, &project_id) + .to_string_lossy() + .to_string() + }); + + DoctorReport::new( + ProjectInfo { + path: canonical.to_string_lossy().to_string(), + framework: framework.is_ok().then(|| "flutter".to_string()), + project_id: Some(project_id), + state_dir, + }, + checks, + ) +} + +fn directory_check(canonical: &Path, error: Option<&std::io::Error>, is_directory: bool) -> Check { + let display = canonical.display(); + match (error, is_directory) { + (None, true) => Check::new( + "project.directory", + CheckStatus::Pass, + format!("project directory resolved: {display}"), + ), + (None, false) => Check::new( + "project.directory", + CheckStatus::Fail, + format!("project path is not a directory: {display}"), + ) + .with_remediation("point --project-dir at a Flutter project directory"), + (Some(error), _) => Check::new( + "project.directory", + CheckStatus::Fail, + format!("project directory not found: {display}"), + ) + .with_detail(error.to_string()) + .with_remediation("point --project-dir at an existing Flutter project directory"), + } +} + +fn framework_check(canonical: &Path, framework: &Result<(), FrameworkError>) -> Check { + match framework { + Ok(()) => Check::new( + "project.framework", + CheckStatus::Pass, + "Flutter project detected (pubspec.yaml declares the Flutter SDK)", + ), + Err(error) => Check::new( + "project.framework", + CheckStatus::Fail, + "no Flutter project detected", + ) + .with_detail(format!( + "{}: {error}", + canonical.join("pubspec.yaml").display() + )) + .with_remediation( + "run this against a Flutter app whose pubspec.yaml has \ + dependencies.flutter.sdk: flutter", + ), + } +} + +fn required_tool_check(env: &Environment, tool: &str) -> Check { + match crate::tools::find_on_path(env, tool) { + Some(path) => Check::new( + &format!("tool.{tool}"), + CheckStatus::Pass, + format!("{tool} found on PATH"), + ) + .with_detail(path.to_string_lossy().to_string()), + None => Check::new( + &format!("tool.{tool}"), + CheckStatus::Fail, + format!("{tool} not found on PATH"), + ) + .with_remediation("install the Flutter SDK and put its bin directory on PATH"), + } +} + +fn harness_check(harness: &str, found: Option<&Path>) -> Check { + match found { + Some(path) => Check::new( + &format!("harness.{harness}"), + CheckStatus::Pass, + format!("{harness} found on PATH"), + ) + .with_detail(path.to_string_lossy().to_string()), + None => Check::new( + &format!("harness.{harness}"), + CheckStatus::Warning, + format!("{harness} not found on PATH"), + ) + .with_remediation(format!("install {harness} to use it as an agent harness")), + } +} + +fn harness_aggregate_check(found: &[&str]) -> Check { + if found.is_empty() { + Check::new( + "harness.available", + CheckStatus::Fail, + "no supported agent harness found on PATH", + ) + .with_detail(format!("looked for: {}", HARNESSES.join(", "))) + .with_remediation("install at least one of claude, codex, or pi") + } else { + Check::new( + "harness.available", + CheckStatus::Pass, + format!("{} agent harness(es) available", found.len()), + ) + .with_detail(found.join(", ")) + } +} + +fn state_check(root: &Result, project_id: &str) -> Check { + match root { + Ok(root) => Check::new( + "storage.state", + CheckStatus::Pass, + format!( + "project state directory resolved: {}", + project_state_dir(root, project_id).display() + ), + ) + .with_detail("not created; doctor never writes"), + Err(error @ StateError::RelativeOverride(_)) => Check::new( + "storage.state", + CheckStatus::Fail, + "project state directory could not be resolved", + ) + .with_detail(error.to_string()) + .with_remediation(format!( + "set {HOME_ENV_VAR} to an absolute path, or unset it" + )), + Err(error @ StateError::NoHomeDirectory) => Check::new( + "storage.state", + CheckStatus::Fail, + "project state directory could not be resolved", + ) + .with_detail(error.to_string()) + .with_remediation(format!("set {HOME_ENV_VAR} to an absolute path")), + } +} diff --git a/crates/pickforge-cli/src/env.rs b/crates/pickforge-cli/src/env.rs new file mode 100644 index 0000000..2796c4f --- /dev/null +++ b/crates/pickforge-cli/src/env.rs @@ -0,0 +1,59 @@ +//! Injectable process environment. +//! +//! Every environment read the diagnostics perform goes through this type, so +//! tests can describe an exact PATH/home/override world without touching the +//! developer's real environment. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The ambient inputs `doctor` is allowed to read: environment variables and +/// the user's home directory. +#[derive(Debug, Clone, Default)] +pub struct Environment { + vars: BTreeMap, + home_dir: Option, +} + +impl Environment { + /// The real process environment. + pub fn from_process() -> Self { + Self { + vars: std::env::vars_os() + .filter_map(|(key, value)| Some((key.into_string().ok()?, value))) + .collect(), + home_dir: directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()), + } + } + + /// An environment with no variables and no home directory. + pub fn empty() -> Self { + Self::default() + } + + #[must_use] + pub fn with_var(mut self, key: impl Into, value: impl Into) -> Self { + self.vars.insert(key.into(), value.into()); + self + } + + #[must_use] + pub fn with_home_dir(mut self, home_dir: impl Into) -> Self { + self.home_dir = Some(home_dir.into()); + self + } + + pub fn var(&self, key: &str) -> Option<&OsString> { + self.vars.get(key) + } + + /// `PATH` as searched for executables; `None` when unset. + pub fn path(&self) -> Option<&OsString> { + self.var("PATH") + } + + pub fn home_dir(&self) -> Option<&Path> { + self.home_dir.as_deref() + } +} diff --git a/crates/pickforge-cli/src/lib.rs b/crates/pickforge-cli/src/lib.rs new file mode 100644 index 0000000..9b6933d --- /dev/null +++ b/crates/pickforge-cli/src/lib.rs @@ -0,0 +1,16 @@ +//! Experimental Pickforge CLI internals. +//! +//! The library owns all diagnostics; the `pickforge` binary is a thin adapter +//! that parses arguments, renders a report, and maps readiness to an exit code. + +pub mod doctor; +pub mod env; +pub mod project; +pub mod render; +pub mod report; +pub mod state; +mod tools; + +pub use doctor::diagnose; +pub use env::Environment; +pub use report::{Check, CheckStatus, DoctorReport, ProjectInfo, SCHEMA_VERSION}; diff --git a/crates/pickforge-cli/src/main.rs b/crates/pickforge-cli/src/main.rs new file mode 100644 index 0000000..2c469f5 --- /dev/null +++ b/crates/pickforge-cli/src/main.rs @@ -0,0 +1,55 @@ +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use pickforge_cli::{diagnose, render, Environment}; + +#[derive(Parser)] +#[command( + name = "pickforge", + about = "Experimental Pickforge CLI", + version, + disable_help_subcommand = true +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Diagnose whether a project is ready for Pickforge (read-only). + Doctor { + /// Project directory to diagnose (defaults to the current directory). + #[arg(long, value_name = "PATH")] + project_dir: Option, + /// Emit the machine-readable report instead of text. + #[arg(long)] + json: bool, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.command { + Command::Doctor { project_dir, json } => { + let project_dir = project_dir + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + let report = diagnose(&project_dir, &Environment::from_process()); + print!( + "{}", + if json { + render::render_json(&report) + } else { + render::render_text(&report) + } + ); + if report.ready { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } + } +} diff --git a/crates/pickforge-cli/src/project.rs b/crates/pickforge-cli/src/project.rs new file mode 100644 index 0000000..61796b6 --- /dev/null +++ b/crates/pickforge-cli/src/project.rs @@ -0,0 +1,128 @@ +//! Project identity and framework detection. + +use std::path::{Component, Path, PathBuf}; + +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +const PROJECT_ID_HASH_LENGTH: usize = 16; +const PROJECT_ID_SLUG_LENGTH: usize = 40; + +/// Absolute path with `.`/`..` removed, without touching the filesystem. +fn lexically_absolute(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from("/")) + .join(path) + }; + + let mut resolved = PathBuf::new(); + for component in absolute.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + resolved.pop(); + } + other => resolved.push(other.as_os_str()), + } + } + resolved +} + +/// Canonical form of a project path used for stable project-id derivation. +/// Resolves symlinks so a project reached through different paths keeps one +/// identity; falls back to the lexically resolved path when the directory does +/// not exist, so id derivation never fails. +pub fn canonical_project_path(project_dir: &Path) -> PathBuf { + std::fs::canonicalize(project_dir).unwrap_or_else(|_| lexically_absolute(project_dir)) +} + +fn sanitize_slug(name: &str) -> String { + let mut slug = String::new(); + for ch in name.to_lowercase().chars() { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() { + slug.push(ch); + } else if !slug.ends_with('-') { + slug.push('-'); + } + } + let slug = slug.trim_matches('-'); + let slug = if slug.is_empty() { "project" } else { slug }; + slug.chars().take(PROJECT_ID_SLUG_LENGTH).collect() +} + +/// Stable per-project id: a readable slug of the directory basename plus the +/// leading hex of a SHA-256 digest over the canonical path. Must stay +/// byte-identical to PickLab's TypeScript `deriveProjectId`. +pub fn derive_project_id(canonical_path: &Path) -> String { + let digest = Sha256::digest(canonical_path.to_string_lossy().as_bytes()); + let hash: String = format!("{digest:x}") + .chars() + .take(PROJECT_ID_HASH_LENGTH) + .collect(); + let basename = canonical_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + format!("{}-{hash}", sanitize_slug(&basename)) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FrameworkError { + #[error("no pubspec.yaml found")] + PubspecMissing, + #[error("pubspec.yaml could not be read: {0}")] + PubspecUnreadable(String), + #[error("pubspec.yaml is not valid YAML: {0}")] + PubspecMalformed(String), + #[error("pubspec.yaml has no dependencies.flutter.sdk: flutter entry")] + NotFlutter, +} + +#[derive(Debug, Deserialize)] +struct Pubspec { + #[serde(default)] + dependencies: Option, +} + +#[derive(Debug, Deserialize)] +struct Dependencies { + #[serde(default)] + flutter: Option, +} + +#[derive(Debug, Deserialize)] +struct FlutterDependency { + #[serde(default)] + sdk: Option, +} + +/// Detect a Flutter project by parsing `pubspec.yaml` structurally: only a +/// `dependencies.flutter.sdk: flutter` entry counts. +pub fn detect_flutter(project_dir: &Path) -> Result<(), FrameworkError> { + let pubspec_path = project_dir.join("pubspec.yaml"); + let raw = match std::fs::read_to_string(&pubspec_path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(FrameworkError::PubspecMissing) + } + Err(error) => return Err(FrameworkError::PubspecUnreadable(error.to_string())), + }; + + let pubspec: Pubspec = serde_yaml_ng::from_str(&raw) + .map_err(|error| FrameworkError::PubspecMalformed(error.to_string()))?; + + let sdk = pubspec + .dependencies + .and_then(|dependencies| dependencies.flutter) + .and_then(|flutter| flutter.sdk); + + if sdk.as_deref() == Some("flutter") { + Ok(()) + } else { + Err(FrameworkError::NotFlutter) + } +} diff --git a/crates/pickforge-cli/src/render.rs b/crates/pickforge-cli/src/render.rs new file mode 100644 index 0000000..d83da0d --- /dev/null +++ b/crates/pickforge-cli/src/render.rs @@ -0,0 +1,57 @@ +//! Deterministic, color-free rendering of a [`DoctorReport`]. + +use crate::report::{CheckStatus, DoctorReport}; + +fn label(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Pass => "PASS", + CheckStatus::Warning => "WARN", + CheckStatus::Fail => "FAIL", + } +} + +/// Pretty JSON with a trailing newline. +pub fn render_json(report: &DoctorReport) -> String { + let mut out = + serde_json::to_string_pretty(report).expect("DoctorReport is a plain serializable struct"); + out.push('\n'); + out +} + +pub fn render_text(report: &DoctorReport) -> String { + let mut out = String::from("pickforge doctor\n"); + out.push_str(&format!("project: {}\n", report.project.path)); + out.push_str(&format!( + "framework: {}\n", + report.project.framework.as_deref().unwrap_or("unknown") + )); + out.push_str(&format!( + "project id: {}\n", + report.project.project_id.as_deref().unwrap_or("unknown") + )); + out.push_str(&format!( + "state dir: {}\n\n", + report.project.state_dir.as_deref().unwrap_or("unresolved") + )); + + for check in &report.checks { + out.push_str(&format!( + "[{}] {}: {}\n", + label(check.status), + check.id, + check.summary + )); + if let Some(detail) = &check.detail { + out.push_str(&format!(" {detail}\n")); + } + if let Some(remediation) = &check.remediation { + out.push_str(&format!(" fix: {remediation}\n")); + } + } + + out.push_str(&format!( + "\nready: {}\n", + if report.ready { "yes" } else { "no" } + )); + out +} diff --git a/crates/pickforge-cli/src/report.rs b/crates/pickforge-cli/src/report.rs new file mode 100644 index 0000000..5921926 --- /dev/null +++ b/crates/pickforge-cli/src/report.rs @@ -0,0 +1,92 @@ +//! The serializable shape of a `pickforge doctor` run. + +use serde::Serialize; + +/// Bumped whenever the JSON shape changes incompatibly. +pub const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CheckStatus { + Pass, + Warning, + Fail, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Check { + /// Stable machine identifier, e.g. `project.framework`. + pub id: String, + pub status: CheckStatus, + /// One line, always present. + pub summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remediation: Option, +} + +impl Check { + pub fn new(id: &str, status: CheckStatus, summary: impl Into) -> Self { + Self { + id: id.to_string(), + status, + summary: summary.into(), + detail: None, + remediation: None, + } + } + + #[must_use] + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + #[must_use] + pub fn with_remediation(mut self, remediation: impl Into) -> Self { + self.remediation = Some(remediation.into()); + self + } +} + +/// What the diagnostics resolved about the target project. Fields are omitted +/// when they could not be resolved at all (a missing home directory leaves no +/// state directory to report). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectInfo { + /// Canonical absolute path when the directory exists, otherwise the + /// lexically resolved absolute path. + pub path: String, + /// `flutter` when a Flutter project was detected. + #[serde(skip_serializing_if = "Option::is_none")] + pub framework: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// `/projects/`; never created by `doctor`. + #[serde(skip_serializing_if = "Option::is_none")] + pub state_dir: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DoctorReport { + pub schema_version: u32, + /// True iff no check failed. + pub ready: bool, + pub project: ProjectInfo, + pub checks: Vec, +} + +impl DoctorReport { + pub fn new(project: ProjectInfo, checks: Vec) -> Self { + Self { + schema_version: SCHEMA_VERSION, + ready: !checks.iter().any(|check| check.status == CheckStatus::Fail), + project, + checks, + } + } +} diff --git a/crates/pickforge-cli/src/state.rs b/crates/pickforge-cli/src/state.rs new file mode 100644 index 0000000..af2f91e --- /dev/null +++ b/crates/pickforge-cli/src/state.rs @@ -0,0 +1,42 @@ +//! Where Pickforge keeps its per-project state. Nothing here creates +//! directories: `doctor` only reports paths. + +use std::path::{Path, PathBuf}; + +use thiserror::Error; + +/// The only state-root override in this PR. +pub const HOME_ENV_VAR: &str = "PICKFORGE_HOME"; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum StateError { + #[error("{HOME_ENV_VAR} must be an absolute path, got \"{0}\"")] + RelativeOverride(String), + #[error("no home directory could be resolved")] + NoHomeDirectory, +} + +/// The state root: `PICKFORGE_HOME` when set to a non-empty absolute path, +/// otherwise `/.pickforge/pickforge`. An empty override behaves as unset. +pub fn state_root(env: &crate::env::Environment) -> Result { + if let Some(raw) = env.var(HOME_ENV_VAR) { + if !raw.is_empty() { + let path = PathBuf::from(raw); + if !path.is_absolute() { + return Err(StateError::RelativeOverride( + path.to_string_lossy().to_string(), + )); + } + return Ok(path); + } + } + + env.home_dir() + .map(|home| home.join(".pickforge").join("pickforge")) + .ok_or(StateError::NoHomeDirectory) +} + +/// `/projects/`. +pub fn project_state_dir(root: &Path, project_id: &str) -> PathBuf { + root.join("projects").join(project_id) +} diff --git a/crates/pickforge-cli/src/tools.rs b/crates/pickforge-cli/src/tools.rs new file mode 100644 index 0000000..ba60609 --- /dev/null +++ b/crates/pickforge-cli/src/tools.rs @@ -0,0 +1,17 @@ +//! Executable discovery. Tools are only located on `PATH`, never executed, and +//! never passed through a shell. + +use std::path::PathBuf; + +use crate::env::Environment; + +/// Locate `name` on the supplied `PATH`. Returns `None` when `PATH` is unset, +/// empty, or holds no match. +pub fn find_on_path(env: &Environment, name: &str) -> Option { + let path = env.path()?; + if path.is_empty() { + return None; + } + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); + which::which_in(name, Some(path), cwd).ok() +} diff --git a/crates/pickforge-cli/tests/cli.rs b/crates/pickforge-cli/tests/cli.rs new file mode 100644 index 0000000..938a0d5 --- /dev/null +++ b/crates/pickforge-cli/tests/cli.rs @@ -0,0 +1,124 @@ +//! Just enough end-to-end coverage to pin output rendering and exit mapping. + +use std::path::{Path, PathBuf}; + +use assert_cmd::Command; +use tempfile::TempDir; + +const FLUTTER_PUBSPEC: &str = "name: demo_app\ndependencies:\n flutter:\n sdk: flutter\n"; + +fn fake_bin(root: &Path, tools: &[&str]) -> PathBuf { + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + for tool in tools { + let path = bin.join(tool); + std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + bin +} + +/// A `pickforge` invocation with an isolated PATH and state root, so the test +/// never sees the developer's tools or home directory. +fn pickforge(root: &Path, tools: &[&str]) -> Command { + let mut command = Command::cargo_bin("pickforge").unwrap(); + command + .env_clear() + .env("PATH", fake_bin(root, tools)) + .env("PICKFORGE_HOME", root.join("state")); + command +} + +fn flutter_project(root: &Path) -> PathBuf { + let project_dir = root.join("app"); + std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::write(project_dir.join("pubspec.yaml"), FLUTTER_PUBSPEC).unwrap(); + project_dir +} + +#[test] +fn a_ready_project_prints_a_text_report_and_exits_zero() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + + let output = pickforge(temp.path(), &["flutter", "dart", "claude"]) + .args(["doctor", "--project-dir"]) + .arg(&project_dir) + .assert() + .success(); + + let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("framework: flutter"), "{stdout}"); + assert!(stdout.contains("state dir: "), "{stdout}"); + assert!(stdout.contains("[PASS] tool.flutter"), "{stdout}"); + assert!(stdout.contains("[WARN] harness.codex"), "{stdout}"); + assert!(stdout.trim_end().ends_with("ready: yes"), "{stdout}"); + assert!(!stdout.contains('\u{1b}'), "output must be color-free"); + assert!(!temp.path().join("state").exists()); +} + +#[test] +fn a_project_with_no_toolchain_exits_one_and_reports_not_ready() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + + let output = pickforge(temp.path(), &[]) + .args(["doctor", "--project-dir"]) + .arg(&project_dir) + .assert() + .code(1); + + let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap(); + assert!(stdout.trim_end().ends_with("ready: no"), "{stdout}"); +} + +#[test] +fn json_output_is_parseable_and_uses_the_documented_field_casing() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + + let output = pickforge(temp.path(), &["flutter", "dart", "pi"]) + .args(["doctor", "--json", "--project-dir"]) + .arg(&project_dir) + .assert() + .success(); + + let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["ready"], true); + assert_eq!(value["project"]["framework"], "flutter"); + assert_eq!(value["checks"][0]["id"], "project.directory"); +} + +#[test] +fn a_missing_project_directory_exits_one_without_panicking() { + let temp = TempDir::new().unwrap(); + + let output = pickforge(temp.path(), &["flutter", "dart", "claude"]) + .args(["doctor", "--json", "--project-dir"]) + .arg(temp.path().join("nope")) + .assert() + .code(1); + + let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + assert_eq!(value["ready"], false); + assert_eq!(value["checks"][0]["status"], "fail"); + let stderr = String::from_utf8(output.get_output().stderr.clone()).unwrap(); + assert!(!stderr.contains("panicked"), "{stderr}"); +} + +#[test] +fn the_current_directory_is_the_default_project() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + + pickforge(temp.path(), &["flutter", "dart", "claude"]) + .current_dir(&project_dir) + .arg("doctor") + .assert() + .success(); +} diff --git a/crates/pickforge-cli/tests/doctor.rs b/crates/pickforge-cli/tests/doctor.rs new file mode 100644 index 0000000..a6e0757 --- /dev/null +++ b/crates/pickforge-cli/tests/doctor.rs @@ -0,0 +1,438 @@ +//! Library-level coverage of `pickforge doctor` through its public interface. + +use std::path::{Path, PathBuf}; + +use pickforge_cli::report::CheckStatus; +use pickforge_cli::{diagnose, project, state, DoctorReport, Environment}; +use tempfile::TempDir; + +const FLUTTER_PUBSPEC: &str = "name: demo_app\ndependencies:\n flutter:\n sdk: flutter\n"; + +fn write_project(dir: &Path, pubspec: Option<&str>) { + std::fs::create_dir_all(dir).unwrap(); + if let Some(pubspec) = pubspec { + std::fs::write(dir.join("pubspec.yaml"), pubspec).unwrap(); + } +} + +/// A PATH directory holding fake executables, so tests never depend on the +/// developer's real toolchain. +fn fake_bin(root: &Path, tools: &[&str]) -> PathBuf { + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + for tool in tools { + let path = bin.join(tool); + std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + bin +} + +fn env_with(root: &Path, tools: &[&str]) -> Environment { + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + Environment::empty() + .with_var("PATH", fake_bin(root, tools)) + .with_home_dir(home) +} + +fn all_tools() -> Vec<&'static str> { + vec!["flutter", "dart", "claude", "codex", "pi"] +} + +fn check<'a>(report: &'a DoctorReport, id: &str) -> &'a pickforge_cli::Check { + report + .checks + .iter() + .find(|check| check.id == id) + .unwrap_or_else(|| panic!("missing check {id}")) +} + +#[test] +fn valid_flutter_project_with_full_toolchain_is_ready() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + + assert!(report.ready, "{report:?}"); + assert_eq!(report.schema_version, 1); + assert_eq!(report.project.framework.as_deref(), Some("flutter")); + assert_eq!( + check(&report, "project.framework").status, + CheckStatus::Pass + ); + assert!(report + .checks + .iter() + .all(|check| check.status != CheckStatus::Fail)); +} + +#[test] +fn check_order_is_stable() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + + let ids: Vec<&str> = report + .checks + .iter() + .map(|check| check.id.as_str()) + .collect(); + assert_eq!( + ids, + vec![ + "project.directory", + "project.framework", + "tool.flutter", + "tool.dart", + "harness.claude", + "harness.codex", + "harness.pi", + "harness.available", + "storage.state", + ] + ); +} + +#[test] +fn non_flutter_pubspec_fails_the_framework_check() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project( + &project_dir, + Some("name: plain_dart\ndependencies:\n http: ^1.0.0\n"), + ); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + + assert!(!report.ready); + assert_eq!(report.project.framework, None); + let framework = check(&report, "project.framework"); + assert_eq!(framework.status, CheckStatus::Fail); + assert!(framework.remediation.is_some()); +} + +#[test] +fn a_flutter_named_dependency_without_the_sdk_entry_is_not_flutter() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project( + &project_dir, + Some("name: tricky\ndependencies:\n flutter: ^1.0.0\n"), + ); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + + assert_eq!( + check(&report, "project.framework").status, + CheckStatus::Fail + ); +} + +#[test] +fn malformed_yaml_fails_cleanly() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project( + &project_dir, + Some("name: demo\n : ][ bad\n\tdependencies\n"), + ); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + + let framework = check(&report, "project.framework"); + assert_eq!(framework.status, CheckStatus::Fail); + assert!(framework.detail.is_some()); +} + +#[test] +fn missing_project_directory_fails_without_panicking() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("nope"); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + + assert!(!report.ready); + assert_eq!( + check(&report, "project.directory").status, + CheckStatus::Fail + ); + assert_eq!( + check(&report, "project.framework").status, + CheckStatus::Fail + ); +} + +#[test] +fn a_file_passed_as_the_project_fails_the_directory_check() { + let temp = TempDir::new().unwrap(); + let file = temp.path().join("pubspec.yaml"); + std::fs::write(&file, FLUTTER_PUBSPEC).unwrap(); + + let report = diagnose(&file, &env_with(temp.path(), &all_tools())); + + assert!(!report.ready); + let directory = check(&report, "project.directory"); + assert_eq!(directory.status, CheckStatus::Fail); + assert!(directory.summary.contains("not a directory")); +} + +#[test] +fn missing_path_fails_the_required_tools_and_harness_checks() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let report = diagnose(&project_dir, &Environment::empty().with_home_dir(home)); + + assert!(!report.ready); + assert_eq!(check(&report, "tool.flutter").status, CheckStatus::Fail); + assert_eq!(check(&report, "tool.dart").status, CheckStatus::Fail); + assert_eq!( + check(&report, "harness.available").status, + CheckStatus::Fail + ); +} + +#[test] +fn empty_path_finds_nothing() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + let report = diagnose( + &project_dir, + &Environment::empty() + .with_var("PATH", "") + .with_home_dir(home), + ); + + assert_eq!(check(&report, "tool.flutter").status, CheckStatus::Fail); +} + +#[test] +fn a_single_harness_keeps_the_aggregate_passing_and_warns_on_the_others() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose( + &project_dir, + &env_with(temp.path(), &["flutter", "dart", "codex"]), + ); + + assert!(report.ready); + assert_eq!(check(&report, "harness.codex").status, CheckStatus::Pass); + assert_eq!( + check(&report, "harness.claude").status, + CheckStatus::Warning + ); + assert_eq!(check(&report, "harness.pi").status, CheckStatus::Warning); + assert_eq!( + check(&report, "harness.available").status, + CheckStatus::Pass + ); +} + +#[test] +fn no_harness_fails_the_aggregate_check() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose(&project_dir, &env_with(temp.path(), &["flutter", "dart"])); + + assert!(!report.ready); + assert_eq!( + check(&report, "harness.available").status, + CheckStatus::Fail + ); +} + +#[test] +fn absolute_pickforge_home_overrides_the_default_root() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + let override_root = temp.path().join("custom-home"); + + let report = diagnose( + &project_dir, + &env_with(temp.path(), &all_tools()).with_var("PICKFORGE_HOME", &override_root), + ); + + assert!(report.ready); + let expected = override_root + .join("projects") + .join(report.project.project_id.clone().unwrap()); + assert_eq!( + report.project.state_dir.as_deref(), + Some(expected.to_string_lossy().as_ref()) + ); +} + +#[test] +fn relative_pickforge_home_fails_the_storage_check() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose( + &project_dir, + &env_with(temp.path(), &all_tools()).with_var("PICKFORGE_HOME", "relative/home"), + ); + + assert!(!report.ready); + let storage = check(&report, "storage.state"); + assert_eq!(storage.status, CheckStatus::Fail); + assert_eq!(report.project.state_dir, None); +} + +#[test] +fn empty_pickforge_home_behaves_as_unset() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose( + &project_dir, + &env_with(temp.path(), &all_tools()).with_var("PICKFORGE_HOME", ""), + ); + + assert!(report.ready); + let expected = temp + .path() + .join("home") + .join(".pickforge") + .join("pickforge") + .join("projects") + .join(report.project.project_id.clone().unwrap()); + assert_eq!( + report.project.state_dir.as_deref(), + Some(expected.to_string_lossy().as_ref()) + ); +} + +#[test] +fn a_missing_home_without_an_override_fails_cleanly() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose( + &project_dir, + &Environment::empty().with_var("PATH", fake_bin(temp.path(), &all_tools())), + ); + + assert!(!report.ready); + assert_eq!(check(&report, "storage.state").status, CheckStatus::Fail); + assert_eq!(report.project.state_dir, None); +} + +#[test] +fn doctor_never_creates_the_state_directories() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + let override_root = temp.path().join("custom-home"); + + let report = diagnose( + &project_dir, + &env_with(temp.path(), &all_tools()).with_var("PICKFORGE_HOME", &override_root), + ); + + assert!(!override_root.exists()); + assert!(!Path::new(report.project.state_dir.as_deref().unwrap()).exists()); + assert!(!temp.path().join("home").join(".pickforge").exists()); + // The project itself is untouched beyond the pubspec the test wrote. + let entries: Vec = std::fs::read_dir(&project_dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!(entries, vec!["pubspec.yaml".to_string()]); +} + +#[test] +fn project_id_matches_the_picklab_algorithm() { + // Cross-checked against PickLab's TypeScript `deriveProjectId`. + assert_eq!( + project::derive_project_id(Path::new("/tmp/My App")), + "my-app-a2b5d505f3ca90ae" + ); +} + +#[test] +fn long_and_unslugabble_basenames_stay_within_the_id_contract() { + let long = format!("/tmp/{}", "a".repeat(80)); + let id = project::derive_project_id(Path::new(&long)); + let (slug, hash) = id.rsplit_once('-').unwrap(); + assert_eq!(slug.len(), 40); + assert_eq!(hash.len(), 16); + + let fallback = project::derive_project_id(Path::new("/tmp/___")); + assert!(fallback.starts_with("project-"), "{fallback}"); +} + +#[cfg(unix)] +#[test] +fn a_symlinked_project_path_derives_the_same_id() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + let link = temp.path().join("link"); + std::os::unix::fs::symlink(&project_dir, &link).unwrap(); + + let env = env_with(temp.path(), &all_tools()); + let direct = diagnose(&project_dir, &env); + let through_link = diagnose(&link, &env); + + assert_eq!(direct.project.project_id, through_link.project.project_id); + assert_eq!(direct.project.path, through_link.project.path); +} + +#[test] +fn state_root_defaults_under_the_pickforge_company_root() { + let env = Environment::empty().with_home_dir("/home/someone"); + assert_eq!( + state::state_root(&env).unwrap(), + Path::new("/home/someone/.pickforge/pickforge") + ); +} + +#[test] +fn json_shape_uses_the_documented_casing_and_omits_absent_optionals() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + let value: serde_json::Value = + serde_json::from_str(&pickforge_cli::render::render_json(&report)).unwrap(); + + let object = value.as_object().unwrap(); + let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!(keys, vec!["checks", "project", "ready", "schemaVersion"]); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["ready"], true); + assert_eq!(value["project"]["framework"], "flutter"); + assert!(value["project"]["projectId"].is_string()); + assert!(value["project"]["stateDir"].is_string()); + assert_eq!(value["checks"][0]["id"], "project.directory"); + assert_eq!(value["checks"][0]["status"], "pass"); + // A passing directory check carries no remediation. + assert!(value["checks"][0].get("remediation").is_none()); + assert!(value["checks"][0].get("detail").is_none()); +} diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 53f98db..b6937a3 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -5,13 +5,25 @@ GitHub release description, then reset it after the release is published. ## User-facing changes -- None yet. +- Experimental `pickforge doctor` (new Rust binary, not yet released or + published): read-only readiness diagnostics for a Flutter project — + `pickforge doctor [--project-dir ] [--json]`. Reports the resolved + project path/id/state directory plus checks for a Flutter `pubspec.yaml`, + `flutter`/`dart` on `PATH`, and at least one agent harness (`claude`, + `codex`, `pi`). Exits 1 when not ready. It never writes, never runs the + tools it finds, and makes no network calls. `PICKFORGE_HOME` (absolute + only) overrides the state root, which defaults to + `~/.pickforge/pickforge`. ## Internal/release changes - Raised the vulnerable `fast-uri` and `hono` overrides, plus lockfile resolutions for both `brace-expansion` majors, `fast-uri`, `hono`, `ip-address`, and `nanoid`, to patched releases. +- Added a Cargo workspace (`crates/pickforge-cli`, `Cargo.lock` committed) and + an independent `rust` CI job (fmt, clippy `-D warnings`, tests). The Bun job, + its pinned Bun version, and all release artifacts are unchanged; the Rust + binary is not part of any release pipeline yet. ## Validation @@ -20,10 +32,19 @@ GitHub release description, then reset it after the release is published. - Pinned Bun 1.3.12 CI: frozen install, typecheck, lint, 1,133 tests pass, one skips, coverage passes at 82.48% lines, and build passes. - The pinned OSV Scanner v2.3.8 image reports no unfiltered advisories. +- `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D + warnings`, `cargo test --workspace --locked` (26 tests: project/framework + detection, tool and harness discovery on a fake `PATH`, `PICKFORGE_HOME` + handling, project-id parity with PickLab's TypeScript algorithm, JSON schema + casing, and CLI exit codes). +- Manual smoke runs of `pickforge doctor` and `pickforge doctor --json` + against temporary fake Flutter and non-Flutter projects with an isolated + `PATH`/`PICKFORGE_HOME`. ### Not tested yet -- None recorded. +- Non-Linux platforms (macOS/Windows) for the Rust binary. +- No packaging, installer, or distribution path for `pickforge` yet. ### Release blockers From ee054290817c4ea83a41a67ca776ad9651d58747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Mon, 17 Aug 2026 15:02:15 -0300 Subject: [PATCH 2/3] fix: harden doctor path boundaries --- .github/workflows/ci.yml | 5 +- crates/pickforge-cli/src/doctor.rs | 43 ++++++--- crates/pickforge-cli/src/env.rs | 17 +++- crates/pickforge-cli/src/project.rs | 101 ++++++++++++------- crates/pickforge-cli/src/render.rs | 31 ++++-- crates/pickforge-cli/src/tools.rs | 33 +++++++ crates/pickforge-cli/tests/cli.rs | 20 +++- crates/pickforge-cli/tests/doctor.rs | 139 ++++++++++++++++++++++++--- 8 files changed, 311 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 700ef8b..582de3a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,10 @@ jobs: # Independent of the Bun job: the experimental `pickforge` Rust binary has no # shared toolchain or artifacts with the TypeScript packages. rust: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/crates/pickforge-cli/src/doctor.rs b/crates/pickforge-cli/src/doctor.rs index 259337a..80a99d9 100644 --- a/crates/pickforge-cli/src/doctor.rs +++ b/crates/pickforge-cli/src/doctor.rs @@ -7,7 +7,9 @@ use std::path::Path; use crate::env::Environment; -use crate::project::{canonical_project_path, derive_project_id, detect_flutter, FrameworkError}; +use crate::project::{ + canonical_project_path, derive_project_id, detect_flutter, FrameworkError, ProjectIdentityError, +}; use crate::report::{Check, CheckStatus, DoctorReport, ProjectInfo}; use crate::state::{project_state_dir, state_root, StateError, HOME_ENV_VAR}; @@ -54,17 +56,20 @@ pub fn diagnose(project_dir: &Path, env: &Environment) -> DoctorReport { let root = state_root(env); checks.push(state_check(&root, &project_id)); - let state_dir = root.as_ref().ok().map(|root| { - project_state_dir(root, &project_id) - .to_string_lossy() - .to_string() - }); + let state_dir = match (&root, &project_id) { + (Ok(root), Ok(project_id)) => Some( + project_state_dir(root, project_id) + .to_string_lossy() + .to_string(), + ), + _ => None, + }; DoctorReport::new( ProjectInfo { path: canonical.to_string_lossy().to_string(), framework: framework.is_ok().then(|| "flutter".to_string()), - project_id: Some(project_id), + project_id: project_id.ok(), state_dir, }, checks, @@ -88,10 +93,10 @@ fn directory_check(canonical: &Path, error: Option<&std::io::Error>, is_director (Some(error), _) => Check::new( "project.directory", CheckStatus::Fail, - format!("project directory not found: {display}"), + format!("project directory could not be accessed: {display}"), ) .with_detail(error.to_string()) - .with_remediation("point --project-dir at an existing Flutter project directory"), + .with_remediation("point --project-dir at an accessible Flutter project directory"), } } @@ -171,9 +176,19 @@ fn harness_aggregate_check(found: &[&str]) -> Check { } } -fn state_check(root: &Result, project_id: &str) -> Check { - match root { - Ok(root) => Check::new( +fn state_check( + root: &Result, + project_id: &Result, +) -> Check { + match (root, project_id) { + (_, Err(error)) => Check::new( + "storage.state", + CheckStatus::Fail, + "project state directory could not be resolved", + ) + .with_detail(error.to_string()) + .with_remediation("move or rename the project to a path that is valid UTF-8"), + (Ok(root), Ok(project_id)) => Check::new( "storage.state", CheckStatus::Pass, format!( @@ -182,7 +197,7 @@ fn state_check(root: &Result, project_id: &str) ), ) .with_detail("not created; doctor never writes"), - Err(error @ StateError::RelativeOverride(_)) => Check::new( + (Err(error @ StateError::RelativeOverride(_)), Ok(_)) => Check::new( "storage.state", CheckStatus::Fail, "project state directory could not be resolved", @@ -191,7 +206,7 @@ fn state_check(root: &Result, project_id: &str) .with_remediation(format!( "set {HOME_ENV_VAR} to an absolute path, or unset it" )), - Err(error @ StateError::NoHomeDirectory) => Check::new( + (Err(error @ StateError::NoHomeDirectory), Ok(_)) => Check::new( "storage.state", CheckStatus::Fail, "project state directory could not be resolved", diff --git a/crates/pickforge-cli/src/env.rs b/crates/pickforge-cli/src/env.rs index 2796c4f..9a235b1 100644 --- a/crates/pickforge-cli/src/env.rs +++ b/crates/pickforge-cli/src/env.rs @@ -8,6 +8,17 @@ use std::collections::BTreeMap; use std::ffi::OsString; use std::path::{Path, PathBuf}; +fn normalize_key(key: String) -> String { + #[cfg(windows)] + { + key.to_ascii_uppercase() + } + #[cfg(not(windows))] + { + key + } +} + /// The ambient inputs `doctor` is allowed to read: environment variables and /// the user's home directory. #[derive(Debug, Clone, Default)] @@ -21,7 +32,7 @@ impl Environment { pub fn from_process() -> Self { Self { vars: std::env::vars_os() - .filter_map(|(key, value)| Some((key.into_string().ok()?, value))) + .filter_map(|(key, value)| Some((normalize_key(key.into_string().ok()?), value))) .collect(), home_dir: directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()), } @@ -34,7 +45,7 @@ impl Environment { #[must_use] pub fn with_var(mut self, key: impl Into, value: impl Into) -> Self { - self.vars.insert(key.into(), value.into()); + self.vars.insert(normalize_key(key.into()), value.into()); self } @@ -45,7 +56,7 @@ impl Environment { } pub fn var(&self, key: &str) -> Option<&OsString> { - self.vars.get(key) + self.vars.get(&normalize_key(key.to_string())) } /// `PATH` as searched for executables; `None` when unset. diff --git a/crates/pickforge-cli/src/project.rs b/crates/pickforge-cli/src/project.rs index 61796b6..b0fc689 100644 --- a/crates/pickforge-cli/src/project.rs +++ b/crates/pickforge-cli/src/project.rs @@ -2,7 +2,6 @@ use std::path::{Component, Path, PathBuf}; -use serde::Deserialize; use sha2::{Digest, Sha256}; use thiserror::Error; @@ -32,12 +31,54 @@ fn lexically_absolute(path: &Path) -> PathBuf { resolved } +#[cfg(windows)] +fn normalize_windows_canonical_path(path: PathBuf) -> PathBuf { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + const VERBATIM_PREFIX: &[u16] = &[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]; + const VERBATIM_UNC_PREFIX: &[u16] = &[ + b'\\' as u16, + b'\\' as u16, + b'?' as u16, + b'\\' as u16, + b'U' as u16, + b'N' as u16, + b'C' as u16, + b'\\' as u16, + ]; + + let wide: Vec = path.as_os_str().encode_wide().collect(); + let normalized = if wide.starts_with(VERBATIM_UNC_PREFIX) { + [vec![b'\\' as u16, b'\\' as u16], wide[8..].to_vec()].concat() + } else if wide.starts_with(VERBATIM_PREFIX) + && wide.get(4).is_some_and(|unit| { + (*unit >= u16::from(b'A') && *unit <= u16::from(b'Z')) + || (*unit >= u16::from(b'a') && *unit <= u16::from(b'z')) + }) + && wide.get(5) == Some(&u16::from(b':')) + { + wide[4..].to_vec() + } else { + return path; + }; + + PathBuf::from(OsString::from_wide(&normalized)) +} + +#[cfg(not(windows))] +fn normalize_windows_canonical_path(path: PathBuf) -> PathBuf { + path +} + /// Canonical form of a project path used for stable project-id derivation. -/// Resolves symlinks so a project reached through different paths keeps one -/// identity; falls back to the lexically resolved path when the directory does -/// not exist, so id derivation never fails. +/// Resolves `.`/`..` before symlinks to match TypeScript's +/// `realpath(path.resolve(projectDir))`, then removes Windows' verbatim prefix. +/// Falls back to the lexical path when the directory does not exist. pub fn canonical_project_path(project_dir: &Path) -> PathBuf { - std::fs::canonicalize(project_dir).unwrap_or_else(|_| lexically_absolute(project_dir)) + let absolute = lexically_absolute(project_dir); + let canonical = std::fs::canonicalize(&absolute).unwrap_or(absolute); + normalize_windows_canonical_path(canonical) } fn sanitize_slug(name: &str) -> String { @@ -54,20 +95,30 @@ fn sanitize_slug(name: &str) -> String { slug.chars().take(PROJECT_ID_SLUG_LENGTH).collect() } +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ProjectIdentityError { + #[error("project path is not valid UTF-8 and cannot match the TypeScript project-id contract")] + NonUtf8Path, +} + /// Stable per-project id: a readable slug of the directory basename plus the /// leading hex of a SHA-256 digest over the canonical path. Must stay -/// byte-identical to PickLab's TypeScript `deriveProjectId`. -pub fn derive_project_id(canonical_path: &Path) -> String { - let digest = Sha256::digest(canonical_path.to_string_lossy().as_bytes()); +/// byte-identical to PickLab's TypeScript `deriveProjectId`. Paths that cannot +/// be represented by that string-based contract fail instead of colliding. +pub fn derive_project_id(canonical_path: &Path) -> Result { + let canonical = canonical_path + .to_str() + .ok_or(ProjectIdentityError::NonUtf8Path)?; + let digest = Sha256::digest(canonical.as_bytes()); let hash: String = format!("{digest:x}") .chars() .take(PROJECT_ID_HASH_LENGTH) .collect(); let basename = canonical_path .file_name() - .map(|name| name.to_string_lossy().to_string()) + .and_then(|name| name.to_str()) .unwrap_or_default(); - format!("{}-{hash}", sanitize_slug(&basename)) + Ok(format!("{}-{hash}", sanitize_slug(basename))) } #[derive(Debug, Error, PartialEq, Eq)] @@ -82,24 +133,6 @@ pub enum FrameworkError { NotFlutter, } -#[derive(Debug, Deserialize)] -struct Pubspec { - #[serde(default)] - dependencies: Option, -} - -#[derive(Debug, Deserialize)] -struct Dependencies { - #[serde(default)] - flutter: Option, -} - -#[derive(Debug, Deserialize)] -struct FlutterDependency { - #[serde(default)] - sdk: Option, -} - /// Detect a Flutter project by parsing `pubspec.yaml` structurally: only a /// `dependencies.flutter.sdk: flutter` entry counts. pub fn detect_flutter(project_dir: &Path) -> Result<(), FrameworkError> { @@ -112,15 +145,15 @@ pub fn detect_flutter(project_dir: &Path) -> Result<(), FrameworkError> { Err(error) => return Err(FrameworkError::PubspecUnreadable(error.to_string())), }; - let pubspec: Pubspec = serde_yaml_ng::from_str(&raw) + let pubspec: serde_yaml_ng::Value = serde_yaml_ng::from_str(&raw) .map_err(|error| FrameworkError::PubspecMalformed(error.to_string()))?; - let sdk = pubspec - .dependencies - .and_then(|dependencies| dependencies.flutter) - .and_then(|flutter| flutter.sdk); + .get("dependencies") + .and_then(|dependencies| dependencies.get("flutter")) + .and_then(|flutter| flutter.get("sdk")) + .and_then(serde_yaml_ng::Value::as_str); - if sdk.as_deref() == Some("flutter") { + if sdk == Some("flutter") { Ok(()) } else { Err(FrameworkError::NotFlutter) diff --git a/crates/pickforge-cli/src/render.rs b/crates/pickforge-cli/src/render.rs index d83da0d..a15b483 100644 --- a/crates/pickforge-cli/src/render.rs +++ b/crates/pickforge-cli/src/render.rs @@ -10,6 +10,18 @@ fn label(status: CheckStatus) -> &'static str { } } +fn terminal_safe(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if character.is_control() { + escaped.extend(character.escape_default()); + } else { + escaped.push(character); + } + } + escaped +} + /// Pretty JSON with a trailing newline. pub fn render_json(report: &DoctorReport) -> String { let mut out = @@ -20,32 +32,35 @@ pub fn render_json(report: &DoctorReport) -> String { pub fn render_text(report: &DoctorReport) -> String { let mut out = String::from("pickforge doctor\n"); - out.push_str(&format!("project: {}\n", report.project.path)); + out.push_str(&format!( + "project: {}\n", + terminal_safe(&report.project.path) + )); out.push_str(&format!( "framework: {}\n", - report.project.framework.as_deref().unwrap_or("unknown") + terminal_safe(report.project.framework.as_deref().unwrap_or("unknown")) )); out.push_str(&format!( "project id: {}\n", - report.project.project_id.as_deref().unwrap_or("unknown") + terminal_safe(report.project.project_id.as_deref().unwrap_or("unknown")) )); out.push_str(&format!( "state dir: {}\n\n", - report.project.state_dir.as_deref().unwrap_or("unresolved") + terminal_safe(report.project.state_dir.as_deref().unwrap_or("unresolved")) )); for check in &report.checks { out.push_str(&format!( "[{}] {}: {}\n", label(check.status), - check.id, - check.summary + terminal_safe(&check.id), + terminal_safe(&check.summary) )); if let Some(detail) = &check.detail { - out.push_str(&format!(" {detail}\n")); + out.push_str(&format!(" {}\n", terminal_safe(detail))); } if let Some(remediation) = &check.remediation { - out.push_str(&format!(" fix: {remediation}\n")); + out.push_str(&format!(" fix: {}\n", terminal_safe(remediation))); } } diff --git a/crates/pickforge-cli/src/tools.rs b/crates/pickforge-cli/src/tools.rs index ba60609..a74fcab 100644 --- a/crates/pickforge-cli/src/tools.rs +++ b/crates/pickforge-cli/src/tools.rs @@ -7,6 +7,7 @@ use crate::env::Environment; /// Locate `name` on the supplied `PATH`. Returns `None` when `PATH` is unset, /// empty, or holds no match. +#[cfg(not(windows))] pub fn find_on_path(env: &Environment, name: &str) -> Option { let path = env.path()?; if path.is_empty() { @@ -15,3 +16,35 @@ pub fn find_on_path(env: &Environment, name: &str) -> Option { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); which::which_in(name, Some(path), cwd).ok() } + +#[cfg(windows)] +pub fn find_on_path(env: &Environment, name: &str) -> Option { + let path = env.path()?; + if path.is_empty() { + return None; + } + let extensions = env.var("PATHEXT")?.to_str()?; + let extensions: Vec<&str> = extensions + .split(';') + .filter(|extension| extension.starts_with('.') && extension.len() > 1) + .collect(); + let cwd = std::env::current_dir().ok()?; + + for directory in std::env::split_paths(path) { + if directory.as_os_str().is_empty() { + continue; + } + let directory = if directory.is_absolute() { + directory + } else { + cwd.join(directory) + }; + for extension in &extensions { + let candidate = directory.join(format!("{name}{extension}")); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} diff --git a/crates/pickforge-cli/tests/cli.rs b/crates/pickforge-cli/tests/cli.rs index 938a0d5..769d2a8 100644 --- a/crates/pickforge-cli/tests/cli.rs +++ b/crates/pickforge-cli/tests/cli.rs @@ -11,12 +11,20 @@ fn fake_bin(root: &Path, tools: &[&str]) -> PathBuf { let bin = root.join("bin"); std::fs::create_dir_all(&bin).unwrap(); for tool in tools { - let path = bin.join(tool); - std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); - #[cfg(unix)] + #[cfg(windows)] { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.join(format!("{tool}.EXE")); + std::fs::copy(std::env::current_exe().unwrap(), path).unwrap(); + } + #[cfg(not(windows))] + { + let path = bin.join(tool); + std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } } } bin @@ -30,6 +38,8 @@ fn pickforge(root: &Path, tools: &[&str]) -> Command { .env_clear() .env("PATH", fake_bin(root, tools)) .env("PICKFORGE_HOME", root.join("state")); + #[cfg(windows)] + command.env("PATHEXT", ".EXE"); command } diff --git a/crates/pickforge-cli/tests/doctor.rs b/crates/pickforge-cli/tests/doctor.rs index a6e0757..c9cec51 100644 --- a/crates/pickforge-cli/tests/doctor.rs +++ b/crates/pickforge-cli/tests/doctor.rs @@ -21,12 +21,20 @@ fn fake_bin(root: &Path, tools: &[&str]) -> PathBuf { let bin = root.join("bin"); std::fs::create_dir_all(&bin).unwrap(); for tool in tools { - let path = bin.join(tool); - std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); - #[cfg(unix)] + #[cfg(windows)] { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = bin.join(format!("{tool}.EXE")); + std::fs::copy(std::env::current_exe().unwrap(), path).unwrap(); + } + #[cfg(not(windows))] + { + let path = bin.join(tool); + std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } } } bin @@ -35,9 +43,12 @@ fn fake_bin(root: &Path, tools: &[&str]) -> PathBuf { fn env_with(root: &Path, tools: &[&str]) -> Environment { let home = root.join("home"); std::fs::create_dir_all(&home).unwrap(); - Environment::empty() + let env = Environment::empty() .with_var("PATH", fake_bin(root, tools)) - .with_home_dir(home) + .with_home_dir(home); + #[cfg(windows)] + let env = env.with_var("PATHEXT", ".EXE"); + env } fn all_tools() -> Vec<&'static str> { @@ -131,9 +142,15 @@ fn a_flutter_named_dependency_without_the_sdk_entry_is_not_flutter() { let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); - assert_eq!( - check(&report, "project.framework").status, - CheckStatus::Fail + let framework = check(&report, "project.framework"); + assert_eq!(framework.status, CheckStatus::Fail); + assert!( + framework + .detail + .as_deref() + .unwrap() + .contains("no dependencies.flutter.sdk: flutter entry"), + "{framework:?}" ); } @@ -368,7 +385,7 @@ fn doctor_never_creates_the_state_directories() { fn project_id_matches_the_picklab_algorithm() { // Cross-checked against PickLab's TypeScript `deriveProjectId`. assert_eq!( - project::derive_project_id(Path::new("/tmp/My App")), + project::derive_project_id(Path::new("/tmp/My App")).unwrap(), "my-app-a2b5d505f3ca90ae" ); } @@ -376,12 +393,12 @@ fn project_id_matches_the_picklab_algorithm() { #[test] fn long_and_unslugabble_basenames_stay_within_the_id_contract() { let long = format!("/tmp/{}", "a".repeat(80)); - let id = project::derive_project_id(Path::new(&long)); + let id = project::derive_project_id(Path::new(&long)).unwrap(); let (slug, hash) = id.rsplit_once('-').unwrap(); assert_eq!(slug.len(), 40); assert_eq!(hash.len(), 16); - let fallback = project::derive_project_id(Path::new("/tmp/___")); + let fallback = project::derive_project_id(Path::new("/tmp/___")).unwrap(); assert!(fallback.starts_with("project-"), "{fallback}"); } @@ -402,6 +419,102 @@ fn a_symlinked_project_path_derives_the_same_id() { assert_eq!(direct.project.path, through_link.project.path); } +#[cfg(unix)] +#[test] +fn dot_segments_are_resolved_before_symlinks_to_match_typescript() { + let temp = TempDir::new().unwrap(); + let nested = temp.path().join("nested"); + let symlink_target = nested.join("real"); + let nested_other = nested.join("other"); + let expected_project = temp.path().join("other"); + write_project(&symlink_target, Some(FLUTTER_PUBSPEC)); + write_project(&nested_other, Some(FLUTTER_PUBSPEC)); + write_project(&expected_project, Some(FLUTTER_PUBSPEC)); + let link = temp.path().join("link"); + std::os::unix::fs::symlink(&symlink_target, &link).unwrap(); + + let env = env_with(temp.path(), &all_tools()); + let through_dot_segment = diagnose(&link.join("..").join("other"), &env); + let expected = diagnose(&expected_project, &env); + + assert_eq!(through_dot_segment.project.path, expected.project.path); + assert_eq!( + through_dot_segment.project.project_id, + expected.project.project_id + ); +} + +#[cfg(unix)] +#[test] +fn non_utf8_project_paths_cannot_collide_in_state_storage() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let temp = TempDir::new().unwrap(); + let first = temp.path().join(OsString::from_vec(b"app-\xfe".to_vec())); + let second = temp.path().join(OsString::from_vec(b"app-\xff".to_vec())); + write_project(&first, Some(FLUTTER_PUBSPEC)); + write_project(&second, Some(FLUTTER_PUBSPEC)); + let env = env_with(temp.path(), &all_tools()); + + let first_report = diagnose(&first, &env); + let second_report = diagnose(&second, &env); + + for report in [first_report, second_report] { + assert!(!report.ready); + assert_eq!(report.project.project_id, None); + assert_eq!(report.project.state_dir, None); + let storage = check(&report, "storage.state"); + assert_eq!(storage.status, CheckStatus::Fail); + assert!(storage.detail.as_deref().unwrap().contains("valid UTF-8")); + } +} + +#[cfg(unix)] +#[test] +fn text_output_escapes_path_control_characters() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app-\u{1b}]0;spoof\u{7}"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + + let report = diagnose(&project_dir, &env_with(temp.path(), &all_tools())); + let text = pickforge_cli::render::render_text(&report); + + assert!(!text.contains('\u{1b}'), "{text:?}"); + assert!(!text.contains('\u{7}'), "{text:?}"); + assert!(text.contains("\\u{1b}"), "{text:?}"); + assert!(text.contains("\\u{7}"), "{text:?}"); +} + +#[cfg(windows)] +#[test] +fn windows_canonical_paths_match_node_without_a_verbatim_prefix() { + let temp = TempDir::new().unwrap(); + let canonical = project::canonical_project_path(temp.path()); + + assert!(!canonical.to_string_lossy().starts_with(r"\\?\")); + assert!(project::derive_project_id(&canonical).is_ok()); +} + +#[cfg(windows)] +#[test] +fn windows_environment_keys_are_case_insensitive() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + let state_root = temp.path().join("state"); + let env = Environment::empty() + .with_var("Path", fake_bin(temp.path(), &all_tools())) + .with_var("pathext", ".EXE") + .with_var("pickforge_home", &state_root) + .with_home_dir(temp.path().join("unused-home")); + + let report = diagnose(&project_dir, &env); + + assert!(report.ready, "{report:?}"); + assert!(Path::new(report.project.state_dir.as_deref().unwrap()).starts_with(state_root)); +} + #[test] fn state_root_defaults_under_the_pickforge_company_root() { let env = Environment::empty().with_home_dir("/home/someone"); From f435a7d972b6ad6f9b0e0be1d17b5ca0badc5170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Mon, 17 Aug 2026 15:11:12 -0300 Subject: [PATCH 3/3] fix: close doctor review findings --- .github/workflows/ci.yml | 1 + crates/pickforge-cli/src/project.rs | 22 ++++++++++++++++++++++ crates/pickforge-cli/src/tools.rs | 11 ++++++++--- crates/pickforge-cli/tests/doctor.rs | 25 +++++++++++++++++++++++++ docs/releases/UNRELEASED.md | 19 +++++++++++-------- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 582de3a..5a76986 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: # shared toolchain or artifacts with the TypeScript packages. rust: strategy: + fail-fast: false matrix: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} diff --git a/crates/pickforge-cli/src/project.rs b/crates/pickforge-cli/src/project.rs index b0fc689..7481c43 100644 --- a/crates/pickforge-cli/src/project.rs +++ b/crates/pickforge-cli/src/project.rs @@ -159,3 +159,25 @@ pub fn detect_flutter(project_dir: &Path) -> Result<(), FrameworkError> { Err(FrameworkError::NotFlutter) } } + +#[cfg(all(test, windows))] +mod tests { + use super::normalize_windows_canonical_path; + use std::path::PathBuf; + + #[test] + fn windows_verbatim_prefix_normalization_covers_disk_unc_and_other_namespaces() { + assert_eq!( + normalize_windows_canonical_path(PathBuf::from(r"\\?\C:\app")), + PathBuf::from(r"C:\app") + ); + assert_eq!( + normalize_windows_canonical_path(PathBuf::from(r"\\?\UNC\server\share\app")), + PathBuf::from(r"\\server\share\app") + ); + assert_eq!( + normalize_windows_canonical_path(PathBuf::from(r"\\?\Volume{0}\app")), + PathBuf::from(r"\\?\Volume{0}\app") + ); + } +} diff --git a/crates/pickforge-cli/src/tools.rs b/crates/pickforge-cli/src/tools.rs index a74fcab..cf14cea 100644 --- a/crates/pickforge-cli/src/tools.rs +++ b/crates/pickforge-cli/src/tools.rs @@ -23,12 +23,15 @@ pub fn find_on_path(env: &Environment, name: &str) -> Option { if path.is_empty() { return None; } - let extensions = env.var("PATHEXT")?.to_str()?; + let extensions = env + .var("PATHEXT") + .and_then(|value| value.to_str()) + .unwrap_or(".COM;.EXE;.BAT;.CMD"); let extensions: Vec<&str> = extensions .split(';') .filter(|extension| extension.starts_with('.') && extension.len() > 1) .collect(); - let cwd = std::env::current_dir().ok()?; + let cwd = std::env::current_dir().ok(); for directory in std::env::split_paths(path) { if directory.as_os_str().is_empty() { @@ -36,8 +39,10 @@ pub fn find_on_path(env: &Environment, name: &str) -> Option { } let directory = if directory.is_absolute() { directory - } else { + } else if let Some(cwd) = &cwd { cwd.join(directory) + } else { + continue; }; for extension in &extensions { let candidate = directory.join(format!("{name}{extension}")); diff --git a/crates/pickforge-cli/tests/doctor.rs b/crates/pickforge-cli/tests/doctor.rs index c9cec51..0ef3f68 100644 --- a/crates/pickforge-cli/tests/doctor.rs +++ b/crates/pickforge-cli/tests/doctor.rs @@ -515,6 +515,31 @@ fn windows_environment_keys_are_case_insensitive() { assert!(Path::new(report.project.state_dir.as_deref().unwrap()).starts_with(state_root)); } +#[cfg(windows)] +#[test] +fn windows_tool_discovery_defaults_invalid_or_missing_pathext() { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().join("app"); + write_project(&project_dir, Some(FLUTTER_PUBSPEC)); + let base = Environment::empty() + .with_var("PATH", fake_bin(temp.path(), &all_tools())) + .with_home_dir(temp.path().join("home")); + + let missing = diagnose(&project_dir, &base); + let invalid = diagnose( + &project_dir, + &base + .clone() + .with_var("PATHEXT", OsString::from_wide(&[0xd800])), + ); + + assert!(missing.ready, "{missing:?}"); + assert!(invalid.ready, "{invalid:?}"); +} + #[test] fn state_root_defaults_under_the_pickforge_company_root() { let env = Environment::empty().with_home_dir("/home/someone"); diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index b6937a3..d6e5a79 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -13,7 +13,8 @@ GitHub release description, then reset it after the release is published. `codex`, `pi`). Exits 1 when not ready. It never writes, never runs the tools it finds, and makes no network calls. `PICKFORGE_HOME` (absolute only) overrides the state root, which defaults to - `~/.pickforge/pickforge`. + `~/.pickforge/pickforge`. Project paths that cannot satisfy the shared + UTF-8 project-id contract fail closed without resolving a state directory. ## Internal/release changes @@ -21,9 +22,10 @@ GitHub release description, then reset it after the release is published. resolutions for both `brace-expansion` majors, `fast-uri`, `hono`, `ip-address`, and `nanoid`, to patched releases. - Added a Cargo workspace (`crates/pickforge-cli`, `Cargo.lock` committed) and - an independent `rust` CI job (fmt, clippy `-D warnings`, tests). The Bun job, - its pinned Bun version, and all release artifacts are unchanged; the Rust - binary is not part of any release pipeline yet. + a `rust` CI matrix for `ubuntu-latest` and `windows-latest` (fmt, clippy `-D + warnings`, tests). The Bun job, its pinned Bun version, and all release + artifacts are unchanged; the Rust binary is not part of any release pipeline + yet. ## Validation @@ -33,17 +35,18 @@ GitHub release description, then reset it after the release is published. one skips, coverage passes at 82.48% lines, and build passes. - The pinned OSV Scanner v2.3.8 image reports no unfiltered advisories. - `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D - warnings`, `cargo test --workspace --locked` (26 tests: project/framework + warnings`, `cargo test --workspace --locked` (29 tests: project/framework detection, tool and harness discovery on a fake `PATH`, `PICKFORGE_HOME` - handling, project-id parity with PickLab's TypeScript algorithm, JSON schema - casing, and CLI exit codes). + handling, project-id parity and path boundary cases, JSON/text safety, and CLI + exit codes). The Windows target also passes cross-target check and clippy; + Windows-native tests run in the CI matrix. - Manual smoke runs of `pickforge doctor` and `pickforge doctor --json` against temporary fake Flutter and non-Flutter projects with an isolated `PATH`/`PICKFORGE_HOME`. ### Not tested yet -- Non-Linux platforms (macOS/Windows) for the Rust binary. +- macOS for the Rust binary. - No packaging, installer, or distribution path for `pickforge` yet. ### Release blockers