From c18a2ae918ac81f963663a02a41b87c1a0340f27 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:19:57 +0900 Subject: [PATCH 01/44] feat(rust): scaffold Rust workspace + CLI skeleton for the ask port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the bun→Rust port (track rust-port-20260704). Adds a Cargo workspace beside the existing TypeScript tree: - crates/ask — lib+bin crate published as `ask-please` (short names `ask` and `ask-cli` are taken on crates.io), binary `ask`. clap-derive CLI mirroring the full command surface (install|add|remove|list|src|docs| fetch|search|skills|cache{ls|gc|clean}); `--version` reads CARGO_PKG_VERSION. - Un-ported commands return a `NotPorted` notice and exit 2 — the surface is honest about what works while the TS build stays the source of truth. - rust-toolchain.toml (1.94.1), rustfmt.toml, workspace release profile. - Track spec/plan documenting topology, crate naming, the Homebrew asset contract to preserve, and the phased migration. Mirrors pleaseai/code-search's distribution blueprint (npm copy-over shim, Homebrew, cargo) minus its csp-specific crates/deps. --- .gitignore | 5 + .please/docs/tracks.jsonl | 1 + .../active/rust-port-20260704/metadata.json | 10 + .../tracks/active/rust-port-20260704/plan.md | 75 +++++ .../tracks/active/rust-port-20260704/spec.md | 98 ++++++ Cargo.lock | 283 ++++++++++++++++++ Cargo.toml | 43 +++ crates/ask/Cargo.toml | 33 ++ crates/ask/README.md | 21 ++ crates/ask/src/cli.rs | 245 +++++++++++++++ crates/ask/src/lib.rs | 40 +++ crates/ask/src/main.rs | 19 ++ rust-toolchain.toml | 4 + rustfmt.toml | 2 + 14 files changed, 879 insertions(+) create mode 100644 .please/docs/tracks/active/rust-port-20260704/metadata.json create mode 100644 .please/docs/tracks/active/rust-port-20260704/plan.md create mode 100644 .please/docs/tracks/active/rust-port-20260704/spec.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/ask/Cargo.toml create mode 100644 crates/ask/README.md create mode 100644 crates/ask/src/cli.rs create mode 100644 crates/ask/src/lib.rs create mode 100644 crates/ask/src/main.rs create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml diff --git a/.gitignore b/.gitignore index 0494255..2bf6ea8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ dist/ # Turbo .turbo/ +# Rust +/target/ +# npm distribution build output (generated platform packages) +/npm/dist/ + # Environment .env .env.* diff --git a/.please/docs/tracks.jsonl b/.please/docs/tracks.jsonl index 5225700..e007e9a 100644 --- a/.please/docs/tracks.jsonl +++ b/.please/docs/tracks.jsonl @@ -24,3 +24,4 @@ {"id":"telemetry-driven-registry-20260413","type":"feature","status":"in_progress","phase":"implement","issue":"#78","created":"2026-04-13","section":"active"} {"id":"ask-skills-command-20260414","type":"feature","status":"review","phase":"finalize","issue":"#86","pr":"#87","created":"2026-04-14","section":"completed"} {"id":"ask-csp-integration-20260701","type":"feature","status":"review","phase":"finalize","created":"2026-07-01","section":"active"} +{"id":"rust-port-20260704","type":"feature","status":"in_progress","phase":"scaffold","created":"2026-07-04","section":"active"} diff --git a/.please/docs/tracks/active/rust-port-20260704/metadata.json b/.please/docs/tracks/active/rust-port-20260704/metadata.json new file mode 100644 index 0000000..fd61a2f --- /dev/null +++ b/.please/docs/tracks/active/rust-port-20260704/metadata.json @@ -0,0 +1,10 @@ +{ + "track_id": "rust-port-20260704", + "type": "feature", + "status": "in_progress", + "created_at": "2026-07-04T00:00:00Z", + "updated_at": "2026-07-04T00:00:00Z", + "issue": "", + "pr": "", + "project": "bun -> rust port; distribute via npm (copy-over shim), Homebrew, cargo" +} diff --git a/.please/docs/tracks/active/rust-port-20260704/plan.md b/.please/docs/tracks/active/rust-port-20260704/plan.md new file mode 100644 index 0000000..9ea1bb4 --- /dev/null +++ b/.please/docs/tracks/active/rust-port-20260704/plan.md @@ -0,0 +1,75 @@ +# Plan — Rust port of `ask` + +Phased migration. TS (`packages/cli/`) stays the published product until the +Rust line reaches parity (mirrors code-search ADR-0003 cut-over). + +## Phase 0 — Scaffold + walking skeleton ← current + +- Root `Cargo.toml` workspace (`members = ["crates/ask"]`), `workspace.package` + (version `0.4.8` matching current CLI, `# x-release-please-version` anchor), + release profile (lto, codegen-units=1, strip). +- `crates/ask/Cargo.toml` — `name = "ask-please"`, `[lib] name = "ask"`, + `[[bin]] name = "ask"`. +- `rust-toolchain.toml` (channel `1.94.1`, rustfmt+clippy), `rustfmt.toml`. +- `crates/ask/src/{lib.rs,main.rs,cli.rs}` — clap derive CLI mirroring the full + command surface. `--version` real; un-ported subcommands print a one-line + "not yet ported (use npm/Homebrew build)" notice and exit non-zero. +- Gate: `cargo build --release` green, `ask --version` prints `0.4.8`, + `ask --help` lists all subcommands. + +## Phase 1 — npm copy-over shim + +- `npm/ask/{package.json,install.js,bin/ask.js,lib/resolve.js}` + + `npm/scripts/generate-platform-packages.mjs` + `npm/README.md`, adapted from + code-search (rename pass). optionalDependencies pin the 6 platform packages. +- Local test: `cargo build --release`, run `bin/ask.js` fallback path against + `target/release/ask` via `resolveDevBinaryPath`. + +## Phase 2 — release + distribution wiring + +- `release-rust.yml` (reusable): cross-compile `ask-cli` → 6 targets. Emit BOTH + the npm asset names (`ask-` per generator) AND the legacy Homebrew + names (`ask-darwin-x64`, …). Simplest: build once per target, stage under both + names, upload all. Verify `./asset --version` in-job. +- Extend `release-please-config.json` extra-files: sync `Cargo.toml` (generic + marker) + `npm/ask/package.json` `$.version`. +- `release.yml`: add npm platform-package publish (Trusted Publishing/OIDC) and + a `cargo publish -p ask-please` step. Keep Homebrew job; swap its binary source + from `bun build --compile` to the Rust artifacts once Phase 3 hits parity. +- Do NOT delete the Bun compile job until cut-over. + +## Phase 3+ — port CLI logic (module dependency order) + +Bottom-up so each layer compiles against ported deps. Move the matching +`bun:test` suite with each module. + +1. `spec` + `schemas` (ask.json union, specFromEntry/entryFromSpec, parseSpec) +2. `io` (readAskJson/writeAskJson lax, findEntry) + ignore-files + markers +3. `registry` (fetch w/ 10s timeout, detectEcosystem, selectBestStrategy) +4. `sources/*` (github clone-at-tag + fallback chain, npm local-first tarball, + web html→md, llms-txt) — needs git, tar+flate2, html2md, http client +5. `resolvers/*` (npm/pypi/pub/maven metadata → owner/repo) +6. `lockfiles/*` (bun/npm/pnpm/yarn/package.json, format-aware) +7. `store/*` (cpDirAtomic verbatimSymlinks, hashDir, verifyEntry, quarantine, + cache ls/gc/clean, STORE_VERSION) +8. `discovery/*` (local-ask, local-intent, local/repo conventions) +9. `agents` + `agents-intent` + `skill` + `skills/*` (marker blocks, symlinks) +10. `commands/*` + `install.rs` orchestrator + `list/*` render +11. Cut over release workflow to Rust artifacts; retire TS compile job. + +## Dependency menu (Phase 3, opt-in per phase) + +`clap` (derive), `serde`/`serde_json`, `anyhow`/`thiserror`, `reqwest` (rustls) +or `ureq`, `tar` + `flate2`, `sha2`, `ignore`, `walkdir`, an html→markdown crate +(`htmd`/`html2md`), `tempfile`, `regex` + `fancy-regex` (parity with TS +lookbehind patterns), `semver`. Git via `git2` or shelling `git` (decide when we +reach `sources/github` — csp shells `git`, matching the TS impl's execFile). + +## Risks / open questions + +- html→markdown crate parity with `node-html-markdown` output — may need + golden-file tests and per-rule tuning. +- git2 (libgit2) vs shelling out — the TS impl shells `git` and relies on + `GITHUB_TOKEN` URL injection + `redactToken`; shelling keeps behavioral parity + and the private-repo host-exact-match auth logic 1:1. Lean shell-out. +- Windows path/symlink behavior for the store (`verbatimSymlinks` analog). diff --git a/.please/docs/tracks/active/rust-port-20260704/spec.md b/.please/docs/tracks/active/rust-port-20260704/spec.md new file mode 100644 index 0000000..75c74c7 --- /dev/null +++ b/.please/docs/tracks/active/rust-port-20260704/spec.md @@ -0,0 +1,98 @@ +# Rust Port of the `ask` CLI + +**Track:** `rust-port-20260704` +**Status:** in_progress · phase: scaffold +**Owner:** Minsu Lee (@amondnet) + +## Goal + +Port the `@pleaseai/ask` CLI from Bun/TypeScript (`packages/cli/`) to Rust, and +ship it through three distribution channels: + +1. **npm** — `@pleaseai/ask` as a thin **copy-over shim** wrapper over the Rust + binary (esbuild/Biome/ast-grep model), preserving `npx @pleaseai/ask`. +2. **Homebrew** — `pleaseai/homebrew-tap` `ask.rb`, unchanged formula contract. +3. **Cargo** — `cargo install ask-please` (binary `ask`) published to crates.io. + +## Reference + +`pleaseai/code-search` (crate `code-search-please`, npm `@pleaseai/csp`) is the +proven blueprint — a Rust rewrite of a TS CLI distributed to exactly these three +channels. We mirror its *structure and distribution machinery*, not its +dependency menu (embeddings/tree-sitter are csp-specific). See its ADR-0003. + +## Key decisions + +### Crate topology — single lib+bin crate (NOT csp's 3-crate split) + +csp splits `csp` / `csp-cli` / `csp-node` because it ships a napi Node SDK and an +MCP server. `ask` has neither (`ask search` *delegates to* csp; there is no +`ask mcp`). So: + +- **One workspace member `crates/ask`** with both `[lib] name = "ask"` and + `[[bin]] name = "ask"`. Published to crates.io as **`ask-please`** (the short + names `ask` and `ask-cli` are already taken — verified via sparse index; + mirrors csp's `code-search-please` fallback). +- **No `ask-node` napi crate** (YAGNI). No `rmcp`/`tokio`/`napi`/`schemars`. +- Dependency menu is derived from `ask`'s real surface, not csp's. + +### Naming / identity (unchanged where user-facing) + +| Thing | Value | +| ---------------- | ---------------------------------------- | +| Binary | `ask` | +| npm wrapper | `@pleaseai/ask` (unchanged) | +| npm platform pkg | `@pleaseai/ask-` | +| crate | `ask-please` (lib name `ask`) | +| Homebrew tap | `pleaseai/homebrew-tap` → `ask.rb` | +| Release tag | `ask-v` (monorepo component tag)| + +### Homebrew contract — MUST be preserved + +Existing `.github/workflows/release.yml` builds these release assets that the tap +formula downloads. The Rust pipeline must emit the **same asset names** so the +formula keeps working unchanged: + +- `ask-darwin-x64`, `ask-darwin-arm64`, `ask-linux-x64`, `ask-linux-arm64` +- plus a `.sha256` for each. + +Note the tag is `ask-v` (NOT csp's `v`), because `packages/cli` +is a release-please component in this monorepo. + +### npm copy-over shim — adopt csp's machinery verbatim (renamed) + +`npm/csp/{bin/csp.js,install.js,lib/resolve.js}` + +`npm/scripts/generate-platform-packages.mjs` are production-grade and portable. +Copy → `npm/ask/…`, rename `csp`→`ask`, `@pleaseai/csp`→`@pleaseai/ask`, +`code-search`→`ask`, binary `csp`/`csp.exe`→`ask`/`ask.exe`. Platform matrix: +`darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, win32-x64`. + +## Command surface to reach parity on + +`ask install | add | remove | list | src | docs | fetch | search | skills | cache {ls|gc|clean}` + +(from `packages/cli/src/index.ts`). Version injection: `env!("CARGO_PKG_VERSION")`. + +## Parity oracle + +The existing `bun:test` suites under `packages/cli/` are the behavioral spec — +port each module's tests alongside it. The `CLAUDE.md` gotchas list enumerates +non-obvious behavioral invariants the Rust port MUST preserve; each becomes a +Rust test: + +- `cpDirAtomic` `verbatimSymlinks: true` +- strict `ask.json` ref validation (`--allow-mutable-ref` escape hatch) +- pnpm/yarn format-aware lockfile parsers (not regex) +- AGENTS.md marker-block byte-range disjointness (`ask-docs` vs `intent-skills`) +- `authenticatedCloneUrl` exact `github.com` host match (token never leaks) +- `AbortSignal.timeout(10s)` on registry fetch → Rust HTTP client timeout +- store layout `/github/////`, no `github/db` +- `ensureCheckout` `skipDocExtraction: true` + +## Migration strategy — phased, TS stays source of truth until parity + +Mirrors csp/ADR-0003. The Rust tree lives beside the TS tree; the Bun-compiled +binary remains the published product until the Rust line reaches parity, then the +release workflow cuts over (asset names identical, so Homebrew is seamless). + +See `plan.md` for the phase breakdown. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..de63c63 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,283 @@ +# 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 = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "ask-please" +version = "0.4.8" +dependencies = [ + "anyhow", + "clap", + "tempfile", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +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 = "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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +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", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b250b11 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +# Cargo workspace for the Rust port of @pleaseai/ask (track rust-port-20260704). +# +# Phase 0 scaffold. The TypeScript implementation under `packages/cli/` remains +# the source of truth and the published product until the Rust line reaches +# behavioral parity, at which point the release pipeline cuts over to the Rust +# binary (same GitHub-release asset names, so the Homebrew tap is unaffected). +[workspace] +resolver = "2" +members = ["crates/ask"] + +[workspace.package] +version = "0.4.8" # x-release-please-version +edition = "2021" +license = "MIT" +repository = "https://github.com/pleaseai/ask" +authors = ["ask maintainers"] + +# Dependencies are added per migration phase (see the track plan). Listing here +# does not fetch; member crates opt in with `.workspace = true`. +[workspace.dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +thiserror = "2" +regex = "1" +fancy-regex = "0.16" +semver = "1" +sha2 = "0.10" +tempfile = "3" +walkdir = "2" +ignore = "0.4" +tar = "0.4" +flate2 = "1" +# HTTP client for registry / npm / github metadata (rustls, no OpenSSL system dep). +ureq = { version = "3", features = ["json"] } + +# Single-binary release profile (small, fast-to-start native binary — the whole +# point of the port over the Bun-compiled artifact). +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml new file mode 100644 index 0000000..2d94122 --- /dev/null +++ b/crates/ask/Cargo.toml @@ -0,0 +1,33 @@ +# Published to crates.io as `ask-please` because the short names `ask` and +# `ask-cli` are already taken (verified via the crates.io sparse index). The +# library name stays `ask` (see `[lib]`) and the binary is `ask`, so +# `cargo install ask-please` yields an `ask` command — matching the npm +# (`@pleaseai/ask`) and Homebrew (`ask`) entrypoints. This mirrors code-search's +# `code-search-please` crate / `csp` binary split. +[package] +name = "ask-please" +description = "Agent Skills Kit — download version-specific library docs for AI coding agents (Rust port of @pleaseai/ask)." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +homepage = "https://github.com/pleaseai/ask" +documentation = "https://docs.rs/ask-please" +readme = "README.md" +keywords = ["agents", "documentation", "skills", "cli", "llm"] +categories = ["command-line-utilities", "development-tools"] + +[lib] +name = "ask" + +[[bin]] +name = "ask" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +anyhow = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/ask/README.md b/crates/ask/README.md new file mode 100644 index 0000000..956e4e2 --- /dev/null +++ b/crates/ask/README.md @@ -0,0 +1,21 @@ +# ask (`ask-please`) + +Rust port of [`@pleaseai/ask`](https://github.com/pleaseai/ask) — the Agent +Skills Kit CLI. It downloads version-specific library documentation and +generates `AGENTS.md` + Claude Code skills so AI agents reference accurate docs +instead of relying on training data. + +The crate is published to crates.io as **`ask-please`** (the short name `ask` is +taken); it installs an **`ask`** binary: + +```bash +cargo install ask-please +ask --help +``` + +The same binary is also distributed via npm (`@pleaseai/ask`, a copy-over shim +wrapper) and Homebrew (`pleaseai/homebrew-tap`). + +> **Status:** in-progress port. The command surface is stable; per-command logic +> is being ported from the TypeScript implementation module by module. See +> `.please/docs/tracks/active/rust-port-20260704/` in the repo. diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs new file mode 100644 index 0000000..108ed51 --- /dev/null +++ b/crates/ask/src/cli.rs @@ -0,0 +1,245 @@ +//! Command-line surface for `ask`, mirroring `packages/cli/src/index.ts`. +//! +//! The parser is the real, stable contract (`ask --help` lists every command); +//! command *bodies* are filled in per migration phase. Un-ported commands return +//! [`NotPorted`](crate::NotPorted) so the surface is honest about what works. + +use clap::{Args, Parser, Subcommand}; + +use crate::NotPorted; + +/// Agent Skills Kit — download version-specific library docs for AI coding agents. +#[derive(Debug, Parser)] +#[command( + name = "ask", + version, + about = "Agent Skills Kit - Download version-specific library docs for AI coding agents", + propagate_version = true +)] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Resolve versions and generate AGENTS.md + SKILL.md for all libraries in ask.json. + Install, + + /// Add a library to ask.json and materialize its docs/skill. + Add(AddArgs), + + /// Remove a library entry from ask.json and delete its skill file. + Remove(RemoveArgs), + + /// List declared libraries with their resolved versions. + List(ListArgs), + + /// Print resolved source-checkout paths for one or more specs. + Src(SrcArgs), + + /// Print candidate documentation paths for a spec. + Docs(DocsArgs), + + /// Warm the checkout cache for one or more specs. + Fetch(FetchArgs), + + /// Semantic code search over a pinned checkout (delegates to csp). + Search(SearchArgs), + + /// Manage standalone agent skills. + Skills(SkillsArgs), + + /// Manage the global ASK documentation store. + Cache(CacheArgs), +} + +#[derive(Debug, Args)] +pub struct AddArgs { + /// Library name or full spec (e.g. next, @mastra/client-js, npm:react). + pub spec: String, + /// Comma-separated docs-path override (non-interactive). + #[arg(long = "docs-paths")] + pub docs_paths: Option, + /// Downgrade an object entry back to a bare-string entry. + #[arg(long = "clear-docs-paths")] + pub clear_docs_paths: bool, + /// Explicit source override (registry auto-detect is bypassed). + #[arg(short = 's', long = "source")] + pub source: Option, + /// Allow a mutable ref (main/master/latest/...) in ask.json. + #[arg(long = "allow-mutable-ref")] + pub allow_mutable_ref: bool, +} + +#[derive(Debug, Args)] +pub struct RemoveArgs { + /// Library name (e.g. next, @mastra/client-js) or full spec. + pub name: String, +} + +#[derive(Debug, Args)] +pub struct ListArgs { + /// Emit the list as JSON matching ListModelSchema. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct SrcArgs { + /// One or more specs (e.g. npm:next, github:owner/repo@v1). + #[arg(required = true)] + pub specs: Vec, + /// Emit resolved paths as JSON. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct DocsArgs { + /// A single spec (e.g. npm:next, github:owner/repo). + pub spec: String, +} + +#[derive(Debug, Args)] +pub struct FetchArgs { + /// One or more specs to warm into the checkout cache. + #[arg(required = true)] + pub specs: Vec, + /// Suppress per-spec progress output. + #[arg(short = 'q', long)] + pub quiet: bool, +} + +#[derive(Debug, Args)] +pub struct SearchArgs { + /// The spec whose pinned checkout to search. + pub spec: String, + /// The search query. + pub query: String, +} + +#[derive(Debug, Args)] +pub struct SkillsArgs { + #[command(subcommand)] + pub command: SkillsCommand, +} + +#[derive(Debug, Subcommand)] +pub enum SkillsCommand { + /// Install standalone skills. + Install, + /// Remove standalone skills. + Remove, + /// List installed standalone skills. + List, +} + +#[derive(Debug, Args)] +pub struct CacheArgs { + #[command(subcommand)] + pub command: CacheCommand, +} + +#[derive(Debug, Subcommand)] +pub enum CacheCommand { + /// List entries in the global ASK store. + Ls(CacheLsArgs), + /// Remove unreferenced entries from the global ASK store. + Gc(CacheGcArgs), + /// Remove legacy store-layout directories (pre-v2). + Clean(CacheCleanArgs), +} + +#[derive(Debug, Args)] +pub struct CacheLsArgs { + /// Filter by kind: npm, github, web, llms-txt. + #[arg(long)] + pub kind: Option, + /// Emit entries as JSON matching CacheLsModelSchema. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct CacheGcArgs { + /// Show what would be removed without deleting. + #[arg(long = "dry-run")] + pub dry_run: bool, + /// Only remove entries older than this duration (e.g. 30d, 12h, 90m, 60s). + #[arg(long = "older-than")] + pub older_than: Option, + /// Emit results as JSON matching CacheGcModelSchema. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, Args)] +pub struct CacheCleanArgs { + /// Remove github/db and github/checkouts left by the pre-v2 store layout. + #[arg(long)] + pub legacy: bool, +} + +/// Dispatch a parsed [`Cli`] to its command. Phase 0: every command is a stub +/// returning [`NotPorted`]; phases fill these in bottom-up (see the track plan). +pub fn run(cli: Cli) -> anyhow::Result<()> { + let name = match cli.command { + Command::Install => "install", + Command::Add(_) => "add", + Command::Remove(_) => "remove", + Command::List(_) => "list", + Command::Src(_) => "src", + Command::Docs(_) => "docs", + Command::Fetch(_) => "fetch", + Command::Search(_) => "search", + Command::Skills(_) => "skills", + Command::Cache(_) => "cache", + }; + Err(NotPorted::new(name).into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn cli_definition_is_valid() { + // Fails to compile/at runtime if the derive produces an inconsistent + // command tree (duplicate flags, bad arg config, etc.). + Cli::command().debug_assert(); + } + + #[test] + fn version_is_the_crate_version() { + // The `#[command(version)]` uses CARGO_PKG_VERSION, kept in lockstep with + // the workspace version anchor that release-please bumps. + assert_eq!( + Cli::command().get_version(), + Some(env!("CARGO_PKG_VERSION")) + ); + } + + #[test] + fn all_top_level_subcommands_are_present() { + let cmd = Cli::command(); + let names: Vec<_> = cmd.get_subcommands().map(|c| c.get_name()).collect(); + for expected in [ + "install", "add", "remove", "list", "src", "docs", "fetch", "search", "skills", "cache", + ] { + assert!(names.contains(&expected), "missing subcommand: {expected}"); + } + } + + #[test] + fn parses_representative_invocations() { + // A cross-section of the surface parses without error. + Cli::try_parse_from(["ask", "install"]).unwrap(); + Cli::try_parse_from(["ask", "add", "next", "--docs-paths", "docs,readme"]).unwrap(); + Cli::try_parse_from(["ask", "list", "--json"]).unwrap(); + Cli::try_parse_from(["ask", "fetch", "npm:next", "github:a/b", "-q"]).unwrap(); + Cli::try_parse_from(["ask", "cache", "gc", "--dry-run", "--older-than", "30d"]).unwrap(); + Cli::try_parse_from(["ask", "skills", "list"]).unwrap(); + } +} diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs new file mode 100644 index 0000000..f81476e --- /dev/null +++ b/crates/ask/src/lib.rs @@ -0,0 +1,40 @@ +//! Core library for the `ask` CLI (Rust port of `@pleaseai/ask`). +//! +//! Phase 0: this crate is a walking skeleton. The command surface is defined in +//! [`cli`], but the per-command logic is ported from the TypeScript +//! implementation (`packages/cli/src/`) module by module in later phases (see +//! the `rust-port-20260704` track). Until a command is ported it reports a +//! "not yet ported" notice via [`NotPorted`]. + +pub mod cli; + +use std::fmt; + +/// Sentinel error for a command whose logic has not been ported to Rust yet. +/// +/// The binary maps this to a clear, non-zero-exit message pointing at the still +/// authoritative TypeScript build, rather than silently doing nothing. +#[derive(Debug)] +pub struct NotPorted { + pub command: &'static str, +} + +impl NotPorted { + pub fn new(command: &'static str) -> Self { + Self { command } + } +} + +impl fmt::Display for NotPorted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "`ask {}` is not ported to the Rust build yet.\n\ + The TypeScript build (`npx @pleaseai/ask {}`) remains the source of truth \ + during the migration — see .please/docs/tracks/active/rust-port-20260704/.", + self.command, self.command + ) + } +} + +impl std::error::Error for NotPorted {} diff --git a/crates/ask/src/main.rs b/crates/ask/src/main.rs new file mode 100644 index 0000000..bc46eac --- /dev/null +++ b/crates/ask/src/main.rs @@ -0,0 +1,19 @@ +//! Binary entrypoint for `ask`. Thin wrapper: parse argv, dispatch, print any +//! error to stderr and exit non-zero (clap already handles `--help`/`--version` +//! and usage errors with its own exit codes). + +use std::process::ExitCode; + +use ask::cli::{self, Cli}; +use clap::Parser; + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli::run(cli) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("ask: {err}"); + ExitCode::from(2) + } + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ef450a1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.94.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..f42c8b3 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2021" +max_width = 100 From 32dda2f53c6c8ffcc475024e052a656c92fec0eb Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:22:48 +0900 Subject: [PATCH 02/44] feat(npm): add copy-over shim wrapper for the Rust ask binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Rust port (track rust-port-20260704). Adapts the proven code-search (@pleaseai/csp) distribution machinery to ask: - npm/ask/ — the @pleaseai/ask wrapper: bin/ask.js runtime launcher (forwards argv/stdio/signals/exit-code), install.js postinstall copy-over (replaces the JS shim with the native binary so .bin/ask hits native code directly — no Node on the hot path), lib/resolve.js shared platform resolver (musl-aware, never overwritten so npm rebuild is idempotent). - optionalDependencies pin six platform packages (@pleaseai/ask-{darwin-arm64,darwin-x64,linux-arm64,linux-x64, linux-x64-musl,win32-x64}). - npm/scripts/generate-platform-packages.mjs materializes npm/dist// from the built ask- assets at release time. The unix asset names double as the Homebrew formula's download names — kept in sync deliberately. Verified locally: the launcher fallback resolves target/release/ask and forwards args + exit code; the generator produces a valid partial-matrix wrapper + platform packages. Not yet wired to publish (Phase 2 cut-over). --- npm/README.md | 85 +++++++++++++ npm/ask/bin/ask.js | 92 +++++++++++++++ npm/ask/install.js | 94 +++++++++++++++ npm/ask/lib/resolve.js | 131 +++++++++++++++++++++ npm/ask/package.json | 45 +++++++ npm/scripts/generate-platform-packages.mjs | 114 ++++++++++++++++++ 6 files changed, 561 insertions(+) create mode 100644 npm/README.md create mode 100644 npm/ask/bin/ask.js create mode 100644 npm/ask/install.js create mode 100644 npm/ask/lib/resolve.js create mode 100644 npm/ask/package.json create mode 100644 npm/scripts/generate-platform-packages.mjs diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..e792cdc --- /dev/null +++ b/npm/README.md @@ -0,0 +1,85 @@ +# npm distribution wrapper + +> Internal note (not published). The published `@pleaseai/ask` package will be +> the Rust-binary wrapper generated from this directory by +> `scripts/generate-platform-packages.mjs`. Until the Rust port reaches parity, +> the published `@pleaseai/ask` is still the Bun-compiled TypeScript CLI from +> `packages/cli/`; this wrapper is prepared and tested but not yet the publish +> source (see track `rust-port-20260704`, the Phase 2 cut-over). + +## Goal + +Preserve the existing entrypoint — `bunx @pleaseai/ask` / `npx @pleaseai/ask` — +while shipping the Rust-compiled `ask` binary instead of a bundled JS CLI. The +layout follows the [Biome](https://github.com/biomejs/biome) optional-dependency +model, and the launch path uses the [esbuild](https://github.com/evanw/esbuild) +**copy-over-shim** optimization: + +- `@pleaseai/ask` (the `ask/` dir) is a thin **wrapper** package. Its `bin` + points at a Node launcher (`bin/ask.js`) that resolves and `exec`s the correct + platform binary — used as a **fallback** when the copy-over did not run. The + fallback forwards argv, stdio, exit code, and termination signals to the child. +- A `postinstall` step (`install.js`) copies the resolved platform binary + **over** `bin/ask.js`, so npm's `.bin/ask` symlink resolves directly to native + code. After install there is **no Node.js process on the hot path**. +- The shared platform resolver lives in `lib/resolve.js` (required by both the + launcher and `install.js`); it is never the file overwritten by the copy-over, + so re-running the postinstall (`npm rebuild`, `npm ci`) is idempotent. +- Per-platform packages (`@pleaseai/ask-`) each carry one prebuilt binary + and declare `os` + `cpu` so npm/bun install only the matching one. +- The wrapper lists every platform package under `optionalDependencies`, so a + failed-to-match platform is skipped rather than failing the whole install. + +### Package-manager note (bun) + +The copy-over runs as a `postinstall` script. **npm** and **pnpm** run it by +default. **bun blocks lifecycle scripts for untrusted dependencies by default**, +so under `bun install` the copy-over does not run and `bin/ask.js` stays the JS +launcher — still fully functional, just without the startup win. bun users who +want the fast path add `@pleaseai/ask` to `trustedDependencies`: + +```jsonc +{ "trustedDependencies": ["@pleaseai/ask"] } +``` + +`bunx @pleaseai/ask` continues to work regardless via the launcher fallback. + +``` +@pleaseai/ask (wrapper — bin/ask.js launcher) +├── @pleaseai/ask-darwin-arm64 (optionalDependency, os=darwin cpu=arm64) +├── @pleaseai/ask-darwin-x64 +├── @pleaseai/ask-linux-x64 +├── @pleaseai/ask-linux-arm64 +├── @pleaseai/ask-linux-x64-musl +└── @pleaseai/ask-win32-x64 (ask.exe) +``` + +## Layout + +- `ask/` — the wrapper package: + - `bin/ask.js` — the runtime launcher. On a successful `postinstall` the + copy-over replaces it with the native binary; it is left in place (and used + as the fallback) on Windows, unsupported platforms, when lifecycle scripts + are skipped, or if the copy fails. + - `install.js` — the `postinstall` copy-over step. + - `lib/resolve.js` — the shared platform resolver (never overwritten). +- `scripts/generate-platform-packages.mjs` — at release time, generates the + per-platform package directories from the built `ask-` assets and the + release version, ready to `npm publish` each one. + +## Release flow + +1. `release-rust.yml` builds `ask-` binaries + checksums. +2. `node npm/scripts/generate-platform-packages.mjs ` + materializes `npm/dist//` for each platform (and the wrapper, with the + repo-root `README.md` + `LICENSE` copied in). +3. Publish each platform package, then the wrapper, with + `npm publish ./ --access public` (CI: `id-token: write`, npm Trusted + Publishing/OIDC — no token, provenance generated automatically). + +## Homebrew note + +The unix asset names emitted by `release-rust.yml` (`ask-darwin-arm64`, +`ask-darwin-x64`, `ask-linux-x64`, `ask-linux-arm64`) are also what the +`pleaseai/homebrew-tap` `ask.rb` formula downloads. Renaming them here would +break `brew install` — keep the two in sync. diff --git a/npm/ask/bin/ask.js b/npm/ask/bin/ask.js new file mode 100644 index 0000000..d9bed8b --- /dev/null +++ b/npm/ask/bin/ask.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// Fallback launcher for the platform-specific `ask` Rust binary. +// +// Normally the postinstall step (`install.js`) copies the native binary OVER +// this file so npm's `.bin/ask` symlink resolves straight to native code — no +// Node.js process on the hot path (esbuild/ast-grep-style copy-over). This JS +// shim is the fallback for when postinstall did not run — e.g. `--ignore-scripts`, +// or bun blocking lifecycle scripts for untrusted deps (`bunx @pleaseai/ask`, +// or `bun install` without `@pleaseai/ask` in `trustedDependencies`). It resolves +// the binary at runtime and execs it, forwarding argv, stdio, signals, and the +// exit code. + +const { spawn } = require('node:child_process') +const process = require('node:process') +const { resolveBinaryPath, resolveDevBinaryPath, resolvePlatformPackage } = require('../lib/resolve.js') + +function main() { + const target = resolvePlatformPackage() + if (target === null) { + process.stderr.write( + `ask: unsupported platform ${process.platform}/${process.arch}.\n` + + 'See https://github.com/pleaseai/ask/releases for prebuilt binaries.\n', + ) + process.exit(1) + } + + const binaryPath = resolveBinaryPath() ?? resolveDevBinaryPath() + if (binaryPath === null) { + process.stderr.write( + `ask: the platform package "${target.pkg}" is not installed.\n` + + 'It should have been pulled in automatically as an optional dependency. ' + + 'Try reinstalling without --no-optional, or download a binary from ' + + 'https://github.com/pleaseai/ask/releases.\n', + ) + process.exit(1) + } + + // This shim only runs when the postinstall copy-over did not (on Windows the + // shim is the intended launcher, so no hint there). Nudge interactive users + // toward the fast path; stay silent on pipes/CI to avoid noise. + if (process.platform !== 'win32' && process.stderr.isTTY && !process.env.ASK_NO_FALLBACK_WARNING) { + process.stderr.write( + 'ask: running via the Node launcher (postinstall copy-over did not run), ' + + 'which adds per-invocation startup overhead.\n' + + 'Reinstall without --ignore-scripts, or under bun add "@pleaseai/ask" to ' + + '"trustedDependencies", for the native fast path. ' + + 'Set ASK_NO_FALLBACK_WARNING=1 to silence this.\n', + ) + } + + const child = spawn(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + windowsHide: true, + }) + + child.on('error', (error) => { + process.stderr.write(`ask: failed to execute native binary: ${error.message}\n`) + process.exit(1) + }) + + // Forward termination signals to the child so a supervisor killing this + // launcher cleanly stops the binary too, rather than orphaning it. + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] + const forward = signals.map((signal) => { + const handler = () => { + if (!child.killed) { + try { + child.kill(signal) + } + catch {} + } + } + process.on(signal, handler) + return [signal, handler] + }) + + child.on('exit', (code, signal) => { + for (const [s, h] of forward) { + process.removeListener(s, h) + } + if (signal) { + // Re-raise the signal on ourselves so the parent observes the same cause + // of death (correct exit status for shells and supervisors). + process.kill(process.pid, signal) + } + else { + process.exit(code ?? 1) + } + }) +} + +main() diff --git a/npm/ask/install.js b/npm/ask/install.js new file mode 100644 index 0000000..c032f51 --- /dev/null +++ b/npm/ask/install.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +// Postinstall copy-over optimization (esbuild-style). +// +// Replace the JS launcher shim at `bin/ask.js` with the actual platform binary +// so npm's `.bin/ask` symlink resolves directly to native code — no Node.js +// process spawned on every invocation. If anything fails (unsupported platform, +// missing optional dependency, read-only filesystem, or a package manager that +// skips lifecycle scripts entirely), the JS shim stays in place and still works +// as a runtime fallback. This step is therefore best-effort and MUST exit 0. +// +// It is also idempotent: it only requires `lib/resolve.js` (never the shim it +// overwrites), so a repeated `npm rebuild` re-copies the binary cleanly instead +// of trying to `require()` a native executable as CommonJS. + +const { chmodSync, copyFileSync, linkSync, renameSync, statSync, unlinkSync } = require('node:fs') +const { join, sep } = require('node:path') +const process = require('node:process') +const { resolveBinaryPath } = require('./lib/resolve.js') + +function main() { + // Only rewrite the launcher when running as an installed dependency. From a + // source checkout `bin/ask.js` is a git-tracked file, and an in-place + // copy-over there would mutate the repo — the generator reads that same file + // into every published wrapper, so one stray local install could ship a + // native binary as the "launcher". An installed package always lives under a + // `node_modules/` path segment; a checkout does not. + if (!__dirname.split(sep).includes('node_modules')) { + return + } + + // On Windows the npm-generated bin shims (ask.cmd / ask.ps1) invoke + // `node bin/ask.js`, so the shim must remain JavaScript. Skip the copy-over + // and rely on the runtime launcher there. + if (process.platform === 'win32') { + return + } + + const binaryPath = resolveBinaryPath() + if (binaryPath === null) { + // Unsupported platform or the optional dependency was not installed; the JS + // shim already prints a helpful message at runtime. + return + } + + const shimPath = join(__dirname, 'bin', 'ask.js') + + // Idempotency short-circuit: if the shim was already replaced by a hard link + // to the binary, there is nothing to do. This matters because POSIX rename() + // between two hard links to the same inode is a no-op that leaves the temp + // file behind — so on a re-run (`npm rebuild`, `npm ci`) we must not enter the + // link+rename path at all. + try { + const shimStat = statSync(shimPath) + const binStat = statSync(binaryPath) + // Match on device + inode: inode numbers are only unique within a + // filesystem, so comparing ino alone could collide across devices. + if (shimStat.dev === binStat.dev && shimStat.ino === binStat.ino) { + return + } + } + catch {} + + const tempPath = `${shimPath}.tmp-${process.pid}` + + try { + // Prefer a hard link (instant, no byte copy, shares the binary's inode); + // fall back to a real copy across filesystems. The platform package already + // ships the binary mode 0755, so only chmod on the copy path (a hard link + // shares the source inode — mutating its mode could touch a shared store). + // Write to a temp path then atomically rename over the shim so a concurrent + // exec never observes a half-written file. + try { + linkSync(binaryPath, tempPath) + } + catch { + copyFileSync(binaryPath, tempPath) + chmodSync(tempPath, 0o755) + } + renameSync(tempPath, shimPath) + } + catch { + // Leave the JS shim in place as the fallback. + } + finally { + // Best-effort cleanup: rename() normally consumes the temp file (ENOENT + // here, ignored); this removes any residue if it did not. + try { + unlinkSync(tempPath) + } + catch {} + } +} + +main() diff --git a/npm/ask/lib/resolve.js b/npm/ask/lib/resolve.js new file mode 100644 index 0000000..040c5c7 --- /dev/null +++ b/npm/ask/lib/resolve.js @@ -0,0 +1,131 @@ +// Shared platform-binary resolution for the `ask` npm wrapper. +// +// This module is required by BOTH the runtime launcher (`bin/ask.js`) and the +// postinstall copy-over step (`install.js`). It must NEVER be the file that the +// copy-over overwrites — `bin/ask.js` is overwritten with the native binary at +// install time, so the resolution logic lives here where it stays JavaScript +// and can be required idempotently (`npm rebuild` runs postinstall again). + +const { existsSync } = require('node:fs') +const { join } = require('node:path') +const process = require('node:process') + +/** + * Map the current platform/arch (plus libc on Linux) to the optional-dependency + * package name and the binary filename it ships. + * + * @returns {{ pkg: string, binary: string } | null} the package/binary pair, or + * null when the current platform/arch is unsupported. + */ +function resolvePlatformPackage() { + const { platform, arch } = process + + if (platform === 'win32') { + if (arch === 'x64') { + return { pkg: '@pleaseai/ask-win32-x64', binary: 'ask.exe' } + } + } + else if (platform === 'darwin') { + if (arch === 'arm64') { + return { pkg: '@pleaseai/ask-darwin-arm64', binary: 'ask' } + } + if (arch === 'x64') { + return { pkg: '@pleaseai/ask-darwin-x64', binary: 'ask' } + } + } + else if (platform === 'linux') { + const musl = isMusl() + if (arch === 'x64') { + return musl + ? { pkg: '@pleaseai/ask-linux-x64-musl', binary: 'ask' } + : { pkg: '@pleaseai/ask-linux-x64', binary: 'ask' } + } + if (arch === 'arm64') { + // Only a glibc arm64 build exists. On musl arm64 (Alpine) return null so + // the launcher reports an unsupported platform rather than exec'ing an + // incompatible glibc binary. + return musl ? null : { pkg: '@pleaseai/ask-linux-arm64', binary: 'ask' } + } + } + + return null +} + +/** Best-effort libc detection: report.glibcVersionRuntime is absent on musl. */ +function isMusl() { + try { + if (typeof process.report?.getReport !== 'function') { + return false + } + // getReport() defaults to including network info, which can trigger a + // blocking reverse-DNS (PTR) lookup — the opposite of this shim's goal. + // We only need report.header.glibcVersionRuntime, so exclude network data + // for the call and restore the caller's setting afterward. + const previousExcludeNetwork = process.report.excludeNetwork + process.report.excludeNetwork = true + try { + const report = process.report.getReport() + if (report && report.header && report.header.glibcVersionRuntime) { + return false + } + // No glibc runtime reported → assume musl (e.g. Alpine). + return report != null + } + finally { + process.report.excludeNetwork = previousExcludeNetwork + } + } + catch { + return false + } +} + +/** + * Resolve the absolute path to the platform binary shipped by the matching + * optional-dependency package, or `null` if the platform is unsupported or the + * package is not installed. + * + * @returns {string | null} the absolute binary path, or null when unsupported + * or the platform package is not installed. + */ +function resolveBinaryPath() { + const target = resolvePlatformPackage() + if (target === null) { + return null + } + try { + return require.resolve(`${target.pkg}/${target.binary}`) + } + catch { + return null + } +} + +/** + * Locate a binary built into the repo's `target/` dir, for running the shim + * straight from a source checkout (no published platform package installed). + * + * This is intentionally NOT consulted by the postinstall copy-over — it must + * never copy a dev binary over the source `bin/ask.js`. Only the runtime + * fallback launcher uses it, as a last resort after {@link resolveBinaryPath}. + * + * @returns {string | null} the absolute path to a locally built binary, or null. + */ +function resolveDevBinaryPath() { + const target = resolvePlatformPackage() + if (target === null) { + return null + } + // lib/resolve.js → npm/ask/lib → npm/ask → npm → + for (const profile of ['release', 'debug']) { + const dev = join(__dirname, '..', '..', '..', 'target', profile, target.binary) + if (existsSync(dev)) { + return dev + } + } + return null +} + +// isMusl stays private — it is an internal helper of resolvePlatformPackage; +// exporting it would invite callers to drift from the canonical resolution. +module.exports = { resolvePlatformPackage, resolveBinaryPath, resolveDevBinaryPath } diff --git a/npm/ask/package.json b/npm/ask/package.json new file mode 100644 index 0000000..dbda12c --- /dev/null +++ b/npm/ask/package.json @@ -0,0 +1,45 @@ +{ + "name": "@pleaseai/ask", + "version": "0.4.8", + "description": "Agent Skills Kit — download version-specific library docs for AI coding agents (Rust binary, npm-distributed).", + "license": "MIT", + "homepage": "https://github.com/pleaseai/ask", + "repository": { + "type": "git", + "url": "https://github.com/pleaseai/ask.git" + }, + "bugs": { + "url": "https://github.com/pleaseai/ask/issues" + }, + "keywords": [ + "agents", + "documentation", + "skills", + "cli", + "llm", + "claude", + "agents-md" + ], + "bin": { + "ask": "bin/ask.js" + }, + "files": [ + "bin/ask.js", + "install.js", + "lib/resolve.js" + ], + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "postinstall": "node install.js" + }, + "optionalDependencies": { + "@pleaseai/ask-darwin-arm64": "0.0.0", + "@pleaseai/ask-darwin-x64": "0.0.0", + "@pleaseai/ask-linux-arm64": "0.0.0", + "@pleaseai/ask-linux-x64": "0.0.0", + "@pleaseai/ask-linux-x64-musl": "0.0.0", + "@pleaseai/ask-win32-x64": "0.0.0" + } +} diff --git a/npm/scripts/generate-platform-packages.mjs b/npm/scripts/generate-platform-packages.mjs new file mode 100644 index 0000000..7f2d0fa --- /dev/null +++ b/npm/scripts/generate-platform-packages.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Generate the per-platform npm packages from built release assets. +// (track rust-port-20260704, Phase 1/2). Usage: +// +// node npm/scripts/generate-platform-packages.mjs +// +// holds the ask-[.exe] binaries produced by +// release-rust.yml. For each known target it writes npm/dist// containing +// a package.json (with os/cpu/libc constraints) and the binary, plus a wrapper +// package.json with pinned optionalDependencies. Publish each with +// `npm publish ./ --access public` (Trusted Publishing adds provenance). + +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const npmRoot = resolve(here, '..') + +// asset = the file name emitted by release-rust.yml; binary = its name inside +// the published package (matches bin/ask.js resolution). The unix asset names +// (ask-darwin-x64, …) are ALSO the ones the Homebrew formula downloads — do not +// rename them without updating pleaseai/homebrew-tap. +const TARGETS = [ + { pkg: '@pleaseai/ask-darwin-arm64', asset: 'ask-darwin-arm64', binary: 'ask', os: 'darwin', cpu: 'arm64' }, + { pkg: '@pleaseai/ask-darwin-x64', asset: 'ask-darwin-x64', binary: 'ask', os: 'darwin', cpu: 'x64' }, + { pkg: '@pleaseai/ask-linux-x64', asset: 'ask-linux-x64', binary: 'ask', os: 'linux', cpu: 'x64', libc: 'glibc' }, + { pkg: '@pleaseai/ask-linux-arm64', asset: 'ask-linux-arm64', binary: 'ask', os: 'linux', cpu: 'arm64', libc: 'glibc' }, + { pkg: '@pleaseai/ask-linux-x64-musl', asset: 'ask-linux-x64-musl', binary: 'ask', os: 'linux', cpu: 'x64', libc: 'musl' }, + { pkg: '@pleaseai/ask-win32-x64', asset: 'ask-windows-x64.exe', binary: 'ask.exe', os: 'win32', cpu: 'x64' }, +] + +const [, , version, assetsDir] = process.argv +if (!version || !assetsDir) { + process.stderr.write('usage: generate-platform-packages.mjs \n') + process.exit(1) +} + +const distRoot = join(npmRoot, 'dist') +mkdirSync(distRoot, { recursive: true }) + +// Repo root holds the README + LICENSE shipped inside the published packages. +const repoRoot = resolve(npmRoot, '..') + +const base = JSON.parse(readFileSync(join(npmRoot, 'ask', 'package.json'), 'utf8')) + +// Generate a package per target whose asset is present. A missing asset is +// skipped with a warning (so a partial matrix can still publish what built); +// only generated targets are pinned in the wrapper's optionalDependencies. +const generated = [] +for (const t of TARGETS) { + const src = join(assetsDir, t.asset) + if (!existsSync(src)) { + process.stderr.write(`skip ${t.pkg}: asset ${t.asset} not found in ${assetsDir}\n`) + continue + } + + const outDir = join(distRoot, t.pkg.replace('/', '__')) + mkdirSync(outDir, { recursive: true }) + + const pkg = { + name: t.pkg, + version, + description: `ask binary for ${t.os}-${t.cpu}${t.libc ? ` (${t.libc})` : ''}.`, + homepage: base.homepage, + repository: base.repository, + license: base.license, + os: [t.os], + cpu: [t.cpu], + ...(t.libc ? { libc: [t.libc] } : {}), + files: [t.binary], + } + writeFileSync(join(outDir, 'package.json'), `${JSON.stringify(pkg, null, 2)}\n`) + + const dest = join(outDir, t.binary) + copyFileSync(src, dest) + chmodSync(dest, 0o755) + // Ship LICENSE in each platform package too — these are published + // independently, and license-compliance scanners (FOSSA, Snyk, …) look for a + // LICENSE file in every package directory. + copyFileSync(join(repoRoot, 'LICENSE'), join(outDir, 'LICENSE')) + generated.push(t) + process.stdout.write(`wrote ${t.pkg}@${version} (${t.asset} -> ${t.binary})\n`) +} + +if (generated.length === 0) { + process.stderr.write('error: no assets matched any known target — nothing generated\n') + process.exit(1) +} + +// Stamp the wrapper with the release version + pinned optionalDependencies +// (only the targets actually generated this run). +const wrapper = { + ...base, + version, + optionalDependencies: Object.fromEntries(generated.map(t => [t.pkg, version])), +} +const wrapperDir = join(distRoot, 'ask') +mkdirSync(join(wrapperDir, 'bin'), { recursive: true }) +mkdirSync(join(wrapperDir, 'lib'), { recursive: true }) +writeFileSync(join(wrapperDir, 'package.json'), `${JSON.stringify(wrapper, null, 2)}\n`) +// The runtime launcher shim (overwritten by the postinstall copy-over), the +// postinstall step itself, and the shared resolver both of them require. +copyFileSync(join(npmRoot, 'ask', 'bin', 'ask.js'), join(wrapperDir, 'bin', 'ask.js')) +copyFileSync(join(npmRoot, 'ask', 'install.js'), join(wrapperDir, 'install.js')) +copyFileSync(join(npmRoot, 'ask', 'lib', 'resolve.js'), join(wrapperDir, 'lib', 'resolve.js')) + +// Ship the user-facing README + LICENSE in the published wrapper so the npm +// package page renders docs (without these, npm shows "No README data found"). +// npm always includes README.md / LICENSE regardless of the `files` allowlist. +copyFileSync(join(repoRoot, 'README.md'), join(wrapperDir, 'README.md')) +copyFileSync(join(repoRoot, 'LICENSE'), join(wrapperDir, 'LICENSE')) +process.stdout.write(`wrote wrapper @pleaseai/ask@${version}\n`) From 82a475d8795132df543a4b01eab5d16921329129 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:26:41 +0900 Subject: [PATCH 03/44] ci(release): add Rust binary + cargo publish workflows for the ask port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Rust port (track rust-port-20260704). Stages the three distribution channels for the Rust binary without disturbing the live TypeScript pipeline (cut-over happens once Phase 3 reaches parity): - release-rust.yml — cross-compiles crates/ask to six targets. Emits the unix asset names (ask-darwin-arm64/x64, ask-linux-x64/arm64) IDENTICAL to the current Bun pipeline and the homebrew-tap ask.rb download names, so Homebrew is unaffected at cut-over; plus linux-x64-musl + windows-x64 for the npm platform packages. Tag validation accepts ask-v (monorepo tag). - release-cargo.yml — publishes ask-please to crates.io (cargo install ask-please → ask). dry-run by default on manual dispatch; idempotent (skips an already-published version). - release-please-config.json — the packages/cli component now bumps the Cargo workspace version (/Cargo.toml generic marker) and the npm wrapper (/npm/ask/package.json) in lockstep with @pleaseai/ask. Both workflows are workflow_dispatch + workflow_call (reusable) and are NOT yet wired into the auto-release — they must not publish the stub binary as @pleaseai/ask before parity. Verified: cargo publish --dry-run packages and compiles the crate in isolation; both workflow YAMLs parse. --- .github/workflows/release-cargo.yml | 71 ++++++++++++++ .github/workflows/release-rust.yml | 146 ++++++++++++++++++++++++++++ release-please-config.json | 13 ++- 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-cargo.yml create mode 100644 .github/workflows/release-rust.yml diff --git a/.github/workflows/release-cargo.yml b/.github/workflows/release-cargo.yml new file mode 100644 index 0000000..8d8d28b --- /dev/null +++ b/.github/workflows/release-cargo.yml @@ -0,0 +1,71 @@ +# crates.io publish for the `ask-please` crate (track rust-port-20260704, Phase 2). +# +# Publishes the workspace crate (binary `ask`) to crates.io so `cargo install +# ask-please` works. Runs manually (workflow_dispatch, dry-run by default) and as +# a reusable workflow (workflow_call) to be invoked by release.yml on the CLI +# release_created event at cut-over. +# +# `cargo publish -p ask-please` resolves the workspace-inherited version/deps into +# concrete values in the published manifest, so no pre-flattening is needed. + +name: Release (cargo) + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Verify packaging without publishing (cargo publish --dry-run).' + required: false + default: true + type: boolean + workflow_call: + inputs: + dry_run: + description: 'Verify packaging without publishing.' + required: false + default: false + type: boolean + secrets: + CARGO_REGISTRY_TOKEN: + required: false + +permissions: + contents: read + +concurrency: + group: release-cargo + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Package (always) and publish (unless dry-run / already published) + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + VERSION="$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; m=json.load(sys.stdin); print(next(p["version"] for p in m["packages"] if p["name"]=="ask-please"))')" + echo "ask-please version: $VERSION" + + # Always validate packaging. + cargo publish -p ask-please --locked --dry-run + + if [ "$DRY_RUN" = "true" ]; then + echo "dry-run: skipping publish" + exit 0 + fi + + # Idempotent: skip if this version is already on crates.io (a re-run + # after a partial release must not hard-fail). + if curl -sf "https://index.crates.io/as/k-/ask-please" | grep -q "\"vers\":\"$VERSION\""; then + echo "ask-please@$VERSION already published — skipping" + exit 0 + fi + + cargo publish -p ask-please --locked diff --git a/.github/workflows/release-rust.yml b/.github/workflows/release-rust.yml new file mode 100644 index 0000000..dc8545b --- /dev/null +++ b/.github/workflows/release-rust.yml @@ -0,0 +1,146 @@ +# Rust release pipeline (track rust-port-20260704, Phase 2). +# +# Cross-compiles the `ask` binary from the Rust workspace (crates/ask) for every +# supported target. Runs two ways: **manually** (workflow_dispatch) for ad-hoc +# rebuilds/validation, and **as a reusable workflow** (workflow_call) to be wired +# into release.yml at the cut-over that makes the Rust binary the published +# product (replacing the Bun-compiled TypeScript binary). +# +# The unix asset names (ask-darwin-arm64, ask-darwin-x64, ask-linux-x64, +# ask-linux-arm64) are IDENTICAL to what the current Bun pipeline emits and what +# the pleaseai/homebrew-tap `ask.rb` formula downloads — so the Homebrew tap +# keeps working unchanged after cut-over. Do not rename them. The extra targets +# (linux-x64-musl, windows-x64) are for the npm platform packages. + +name: Release (Rust) + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to upload assets to (e.g. ask-v0.4.8). Leave blank to only build + upload artifacts.' + required: false + type: string + workflow_call: + inputs: + tag: + description: Release tag to upload assets to (e.g. ask-v0.4.8). + required: false + type: string + +permissions: + contents: read + +concurrency: + group: release-rust-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 # Apple Silicon + target: aarch64-apple-darwin + asset: ask-darwin-arm64 + - os: macos-15-intel # Intel + target: x86_64-apple-darwin + asset: ask-darwin-x64 + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset: ask-linux-x64 + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + asset: ask-linux-arm64 + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + asset: ask-linux-x64-musl + - os: windows-latest + target: x86_64-pc-windows-msvc + asset: ask-windows-x64.exe + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # rust-toolchain.toml pins the channel; rustup honors it. Add the target + # triple so cross-target builds resolve their std. + - name: Add target + run: rustup target add ${{ matrix.target }} + + # The static musl target needs musl-tools for the linker. The crate tree is + # currently pure-Rust (no C/C++ deps), so plain cargo build suffices — if a + # C-linking dependency is added later this may need cargo-zigbuild (see the + # code-search release-rust.yml for that pattern). + - name: Install musl tools + if: ${{ endsWith(matrix.target, '-musl') }} + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Build release binary + run: cargo build --release --locked -p ask-please --target ${{ matrix.target }} + + - name: Stage asset (unix) + if: ${{ !startsWith(matrix.os, 'windows') }} + run: | + cp "target/${{ matrix.target }}/release/ask" "${{ matrix.asset }}" + ./${{ matrix.asset }} --version + shasum -a 256 "${{ matrix.asset }}" > "${{ matrix.asset }}.sha256" + + - name: Stage asset (windows) + if: ${{ startsWith(matrix.os, 'windows') }} + shell: bash + run: | + cp "target/${{ matrix.target }}/release/ask.exe" "${{ matrix.asset }}" + ./${{ matrix.asset }} --version + sha256sum "${{ matrix.asset }}" > "${{ matrix.asset }}.sha256" + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.asset }} + path: | + ${{ matrix.asset }} + ${{ matrix.asset }}.sha256 + if-no-files-found: error + + # Resilient by design: runs even if some build legs failed (always()), so the + # targets that did build still reach the release. Fails only if NOTHING built. + upload-release-assets: + needs: build + if: ${{ always() && inputs.tag != '' }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: artifacts + + - name: Prepare release assets + run: | + mkdir -p release + find artifacts -type f -exec cp {} release/ \; 2>/dev/null || true + count=$(find release -type f | wc -l | tr -d ' ') + echo "staged $count asset file(s):" + ls -lh release/ || true + if [ "$count" -eq 0 ]; then + echo "::error::no built assets to upload — every build leg failed" >&2 + exit 1 + fi + + - name: Upload to release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + GH_REPO: ${{ github.repository }} + run: | + # Validate the tag format before use so an untrusted dispatch input + # can't inject shell. ask uses monorepo component tags: ask-v. + [[ "$RELEASE_TAG" =~ ^ask-v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || { + echo "Invalid release tag format: $RELEASE_TAG (expected ask-v)" >&2 + exit 1 + } + gh release upload "$RELEASE_TAG" release/* --clobber diff --git a/release-please-config.json b/release-please-config.json index 369e909..bf6176c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,7 +16,18 @@ "package-name": "@pleaseai/ask", "changelog-path": "CHANGELOG.md", "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true + "bump-patch-for-minor-pre-major": true, + "extra-files": [ + { + "type": "generic", + "path": "/Cargo.toml" + }, + { + "type": "json", + "path": "/npm/ask/package.json", + "jsonpath": "$.version" + } + ] }, ".": { "release-type": "simple", From dcfc869865fed281a99c9c295d13e832a804a12a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:28:27 +0900 Subject: [PATCH 04/44] feat(rust): port spec parsing (parseSpec/slugifyNpmName) with parity tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port (track rust-port-20260704) — first logic module. Ports packages/cli/src/spec.ts: parse_spec → ParsedSpec {Npm|Github|Unknown}, library_name_from_spec, slugify_npm_name. Preserves the exact slug rules (scoped npm flatten `@mastra/client-js`→`mastra-client-js`, github repo split on the first slash, no-colon → unknown with empty ecosystem). The scoped-name regex `^@[^/]+/[^/]+$` is reproduced without a regex dep via split_once. 9 tests cover the doc-comment examples plus edge cases (deep scope path, subpath repo, unknown ecosystem passthrough). --- crates/ask/src/lib.rs | 1 + crates/ask/src/spec.rs | 231 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 crates/ask/src/spec.rs diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index f81476e..76e92cc 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -7,6 +7,7 @@ //! "not yet ported" notice via [`NotPorted`]. pub mod cli; +pub mod spec; use std::fmt; diff --git a/crates/ask/src/spec.rs b/crates/ask/src/spec.rs new file mode 100644 index 0000000..ea2279f --- /dev/null +++ b/crates/ask/src/spec.rs @@ -0,0 +1,231 @@ +//! Spec parsing for `ask.json` library entries — Rust port of +//! `packages/cli/src/spec.ts`. +//! +//! The spec string is the user-facing identifier; the library *name* is the slug +//! used for `.ask/docs/@/` and `.claude/skills/-docs/`. Slug +//! derivation: +//! +//! - `npm:next` → `next` +//! - `npm:@mastra/client-js` → `mastra-client-js` (scoped flatten) +//! - `github:vercel/next.js` → `next.js` + +/// A parsed `ask.json` spec string. Discriminated the same way as the TS +/// `ParsedSpec` union. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParsedSpec { + Npm { + pkg: String, + name: String, + }, + Github { + owner: String, + repo: String, + name: String, + }, + Unknown { + ecosystem: String, + payload: String, + name: String, + }, +} + +impl ParsedSpec { + /// The library slug, regardless of variant. + pub fn name(&self) -> &str { + match self { + ParsedSpec::Npm { name, .. } + | ParsedSpec::Github { name, .. } + | ParsedSpec::Unknown { name, .. } => name, + } + } +} + +/// Parse a spec string into its ecosystem-tagged form. +/// +/// Mirrors `parseSpec` in `spec.ts`: the ecosystem is everything before the +/// first `:`; a spec with no `:` is `unknown` with an empty ecosystem. +pub fn parse_spec(spec: &str) -> ParsedSpec { + let Some(colon_idx) = spec.find(':') else { + return ParsedSpec::Unknown { + ecosystem: String::new(), + payload: spec.to_string(), + name: spec.to_string(), + }; + }; + let ecosystem = &spec[..colon_idx]; + let payload = &spec[colon_idx + 1..]; + + if ecosystem == "npm" { + return ParsedSpec::Npm { + pkg: payload.to_string(), + name: slugify_npm_name(payload), + }; + } + + if ecosystem == "github" { + // The github repo is everything after the FIRST slash (repo names never + // contain a slash, but owners don't either — split on the first). + match payload.split_once('/') { + Some((owner, repo)) => { + return ParsedSpec::Github { + owner: owner.to_string(), + repo: repo.to_string(), + name: repo.to_string(), + }; + } + None => { + return ParsedSpec::Unknown { + ecosystem: ecosystem.to_string(), + payload: payload.to_string(), + name: payload.to_string(), + }; + } + } + } + + ParsedSpec::Unknown { + ecosystem: ecosystem.to_string(), + payload: payload.to_string(), + name: payload.to_string(), + } +} + +/// The library slug for a spec (`parse_spec(spec).name()`). +pub fn library_name_from_spec(spec: &str) -> String { + parse_spec(spec).name().to_string() +} + +/// `@mastra/client-js` → `mastra-client-js`. Scoped npm names are not valid as +/// `.ask/docs/` or Claude Code skill dir names, so we flatten them the same +/// way the registry server does. Non-scoped names pass through unchanged. +pub fn slugify_npm_name(pkg_name: &str) -> String { + if is_scoped_pkg(pkg_name) { + // Drop the leading `@`, then turn the single `/` into `-`. + pkg_name[1..].replacen('/', "-", 1) + } else { + pkg_name.to_string() + } +} + +/// Equivalent of the TS `SCOPED_PKG_RE = /^@[^/]+\/[^/]+$/`: a leading `@`, a +/// non-empty scope with no slash, a single `/`, and a non-empty name with no +/// slash. +fn is_scoped_pkg(s: &str) -> bool { + let Some(rest) = s.strip_prefix('@') else { + return false; + }; + match rest.split_once('/') { + // `split_once` splits on the first `/`, so `scope` is slash-free by + // construction; the name must also be non-empty and slash-free. + Some((scope, name)) => !scope.is_empty() && !name.is_empty() && !name.contains('/'), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn npm_unscoped() { + assert_eq!( + parse_spec("npm:next"), + ParsedSpec::Npm { + pkg: "next".into(), + name: "next".into() + } + ); + } + + #[test] + fn npm_scoped_is_flattened() { + assert_eq!( + parse_spec("npm:@mastra/client-js"), + ParsedSpec::Npm { + pkg: "@mastra/client-js".into(), + name: "mastra-client-js".into(), + } + ); + } + + #[test] + fn github_repo_keeps_dots() { + assert_eq!( + parse_spec("github:vercel/next.js"), + ParsedSpec::Github { + owner: "vercel".into(), + repo: "next.js".into(), + name: "next.js".into(), + } + ); + } + + #[test] + fn github_without_slash_is_unknown() { + assert_eq!( + parse_spec("github:justarepo"), + ParsedSpec::Unknown { + ecosystem: "github".into(), + payload: "justarepo".into(), + name: "justarepo".into(), + } + ); + } + + #[test] + fn no_colon_is_unknown_empty_ecosystem() { + assert_eq!( + parse_spec("next"), + ParsedSpec::Unknown { + ecosystem: String::new(), + payload: "next".into(), + name: "next".into(), + } + ); + } + + #[test] + fn unknown_ecosystem_passthrough() { + assert_eq!( + parse_spec("pypi:fastapi"), + ParsedSpec::Unknown { + ecosystem: "pypi".into(), + payload: "fastapi".into(), + name: "fastapi".into(), + } + ); + } + + #[test] + fn github_owner_repo_with_subpath_splits_on_first_slash() { + // Only the first slash separates owner/repo; the rest stays in repo. + assert_eq!( + parse_spec("github:owner/repo/extra"), + ParsedSpec::Github { + owner: "owner".into(), + repo: "repo/extra".into(), + name: "repo/extra".into(), + } + ); + } + + #[test] + fn library_name_helper_matches_variant_name() { + assert_eq!( + library_name_from_spec("npm:@mastra/client-js"), + "mastra-client-js" + ); + assert_eq!(library_name_from_spec("github:vercel/next.js"), "next.js"); + assert_eq!(library_name_from_spec("react"), "react"); + } + + #[test] + fn slugify_edge_cases() { + assert_eq!(slugify_npm_name("next"), "next"); + assert_eq!(slugify_npm_name("@scope/pkg"), "scope-pkg"); + // Not a valid scoped name (no `/`) — passes through unchanged. + assert_eq!(slugify_npm_name("@notscoped"), "@notscoped"); + // Deep path is not the scoped shape (`^@[^/]+/[^/]+$`) — unchanged. + assert_eq!(slugify_npm_name("@a/b/c"), "@a/b/c"); + } +} From 9bf5203b58da658c4755f236eed8adc259520f8f Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:29:24 +0900 Subject: [PATCH 05/44] docs(rust-port): mark phases 0-2 + spec module complete in track plan --- .../docs/tracks/active/rust-port-20260704/plan.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.please/docs/tracks/active/rust-port-20260704/plan.md b/.please/docs/tracks/active/rust-port-20260704/plan.md index 9ea1bb4..268b5db 100644 --- a/.please/docs/tracks/active/rust-port-20260704/plan.md +++ b/.please/docs/tracks/active/rust-port-20260704/plan.md @@ -3,6 +3,20 @@ Phased migration. TS (`packages/cli/`) stays the published product until the Rust line reaches parity (mirrors code-search ADR-0003 cut-over). +## Progress + +- [x] Phase 0 — workspace + clap CLI skeleton (all subcommands, stubs, `--version`). +- [x] Phase 1 — npm copy-over shim (`npm/ask/`, generator). Launcher + generator + verified locally. +- [x] Phase 2 — release-rust.yml, release-cargo.yml, release-please version sync. + `cargo publish --dry-run` passes locally. NOT wired to auto-publish yet. +- [ ] Phase 3 — logic port (in progress): + - [x] `spec` (parse_spec / slugify_npm_name) — 9 parity tests. + - [ ] schemas / ask.json, io, registry, sources, resolvers, lockfiles, store, + discovery, agents/skills, commands. +- [ ] Cut-over — swap release.yml binary source from Bun compile to Rust; retire + the TS compile job. + ## Phase 0 — Scaffold + walking skeleton ← current - Root `Cargo.toml` workspace (`members = ["crates/ask"]`), `workspace.package` From 8a1c15790b9d154fee5832b9895b3d0ababbc386 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:31:19 +0900 Subject: [PATCH 06/44] feat(rust): port ask.json schema (LibraryEntry union + validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports packages/schema/src/ask-json.ts: AskJson / LibraryEntry (untagged string|object union) / LibraryEntryObject / StoreMode, plus specFromEntry/docsPathsFromEntry/entryFromSpec as methods and a free fn. serde handles structure + `deny_unknown_fields` (zod .strict() parity); an explicit validate() enforces the refinements serde can't express — the spec-string shape (`^[a-z][a-z0-9+-]*:.+$`, hand-rolled to avoid a regex dep) and non-empty docsPaths. Untagged serialization keeps the canonical no-override form a bare JSON string so ask.json stays diff-clean. 10 tests cover mixed entries, string roundtrip, camelCase docsPaths, strictness, and the refinement rejections. Adds serde/serde_json/thiserror to the crate. --- Cargo.lock | 84 +++++++++++++ crates/ask/Cargo.toml | 3 + crates/ask/src/ask_json.rs | 249 +++++++++++++++++++++++++++++++++++++ crates/ask/src/lib.rs | 1 + 4 files changed, 337 insertions(+) create mode 100644 crates/ask/src/ask_json.rs diff --git a/Cargo.lock b/Cargo.lock index de63c63..6b7e317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,10 @@ version = "0.4.8" dependencies = [ "anyhow", "clap", + "serde", + "serde_json", "tempfile", + "thiserror", ] [[package]] @@ -164,6 +167,12 @@ 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.186" @@ -176,6 +185,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + [[package]] name = "once_cell" version = "1.21.4" @@ -225,6 +240,49 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "strsim" version = "0.11.1" @@ -255,6 +313,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -281,3 +359,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml index 2d94122..6596fe9 100644 --- a/crates/ask/Cargo.toml +++ b/crates/ask/Cargo.toml @@ -28,6 +28,9 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/ask/src/ask_json.rs b/crates/ask/src/ask_json.rs new file mode 100644 index 0000000..2e2fdb6 --- /dev/null +++ b/crates/ask/src/ask_json.rs @@ -0,0 +1,249 @@ +//! `ask.json` schema — Rust port of `packages/schema/src/ask-json.ts`. +//! +//! `ask.json` is the single declarative input in the lazy-first architecture: a +//! list of library entries. Each entry is either a bare **spec string** (the +//! canonical, diff-clean form) or an **object** carrying a non-empty `docsPaths` +//! override selected by the user at `ask add` time. +//! +//! Spec strings carry the ecosystem in the prefix (`npm:next`, +//! `npm:@scope/pkg`, `github:owner/repo@v1.2.3`). + +use serde::{Deserialize, Serialize}; + +/// Object form of a library entry — used ONLY when the user selected a subset of +/// candidate documentation paths. `docs_paths` is required and non-empty; an +/// empty override is indistinguishable from the default, so the canonical +/// "no override" form is the bare string. Unknown fields are rejected (parity +/// with the zod `.strict()`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LibraryEntryObject { + pub spec: String, + #[serde(rename = "docsPaths")] + pub docs_paths: Vec, +} + +/// A library entry: a bare spec string, or an object with a `docsPaths` +/// override. Serialized untagged so the string form stays a plain JSON string on +/// disk (existing `ask.json` files render unchanged). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum LibraryEntry { + Spec(String), + WithDocs(LibraryEntryObject), +} + +/// Lazy-first `ask.json` — a list of library entries. Unknown top-level fields +/// are rejected (parity with `.strict()`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AskJson { + pub libraries: Vec, +} + +/// Materialization mode for the eager `--fetch` path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum StoreMode { + Copy, + Link, + Ref, +} + +/// Validation failure for an `ask.json` document, mirroring the zod refinements +/// that plain serde structural parsing does not enforce. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum AskJsonError { + #[error("spec must start with an ecosystem prefix (e.g. \"npm:next\", \"github:owner/repo@v1.2.3\"): {0:?}")] + InvalidSpec(String), + #[error( + "docsPaths override must be non-empty (use a bare spec string for no override): {0:?}" + )] + EmptyDocsPaths(String), + #[error("docsPaths entries must be non-empty strings: {0:?}")] + EmptyDocsPathItem(String), +} + +impl AskJson { + /// Parse and validate an `ask.json` document from a JSON string. + /// + /// Deserialization enforces structure + strictness (`deny_unknown_fields`); + /// [`AskJson::validate`] then enforces the zod refinements (spec-string + /// shape, non-empty `docsPaths`). + pub fn parse(json: &str) -> anyhow::Result { + let parsed: AskJson = serde_json::from_str(json)?; + parsed.validate()?; + Ok(parsed) + } + + /// Run the content refinements the serde layer cannot express. + pub fn validate(&self) -> Result<(), AskJsonError> { + for entry in &self.libraries { + let spec = entry.spec(); + if !is_valid_spec_string(spec) { + return Err(AskJsonError::InvalidSpec(spec.to_string())); + } + if let LibraryEntry::WithDocs(obj) = entry { + if obj.docs_paths.is_empty() { + return Err(AskJsonError::EmptyDocsPaths(spec.to_string())); + } + if obj.docs_paths.iter().any(|p| p.is_empty()) { + return Err(AskJsonError::EmptyDocsPathItem(spec.to_string())); + } + } + } + Ok(()) + } +} + +impl LibraryEntry { + /// Extract the spec string from either form (parity with `specFromEntry`). + pub fn spec(&self) -> &str { + match self { + LibraryEntry::Spec(s) => s, + LibraryEntry::WithDocs(o) => &o.spec, + } + } + + /// The docs-path override, or `None` for the bare-string form + /// (parity with `docsPathsFromEntry`). + pub fn docs_paths(&self) -> Option<&[String]> { + match self { + LibraryEntry::Spec(_) => None, + LibraryEntry::WithDocs(o) => Some(&o.docs_paths), + } + } +} + +/// Build a library entry from a spec and optional docs paths. Canonical-form +/// rule: an empty or absent `docs_paths` collapses to a bare string so +/// `ask.json` stays diff-clean (parity with `entryFromSpec`). +pub fn entry_from_spec(spec: impl Into, docs_paths: &[String]) -> LibraryEntry { + let spec = spec.into(); + if docs_paths.is_empty() { + LibraryEntry::Spec(spec) + } else { + LibraryEntry::WithDocs(LibraryEntryObject { + spec, + docs_paths: docs_paths.to_vec(), + }) + } +} + +/// Equivalent of the zod `SpecString` regex `^[a-z][a-z0-9+-]*:.+$`: a lowercase +/// ecosystem prefix, then `:`, then at least one payload char. +fn is_valid_spec_string(spec: &str) -> bool { + let Some((prefix, payload)) = spec.split_once(':') else { + return false; + }; + if payload.is_empty() { + return false; + } + let mut chars = prefix.chars(); + match chars.next() { + Some(c) if c.is_ascii_lowercase() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_mixed_string_and_object_entries() { + let json = r#"{ + "libraries": [ + "npm:next", + { "spec": "npm:zod", "docsPaths": ["docs/API.md"] }, + "github:vercel/ai@v5.0.0" + ] + }"#; + let ask = AskJson::parse(json).unwrap(); + assert_eq!(ask.libraries.len(), 3); + assert_eq!(ask.libraries[0], LibraryEntry::Spec("npm:next".into())); + assert_eq!(ask.libraries[1].spec(), "npm:zod"); + assert_eq!( + ask.libraries[1].docs_paths(), + Some(&["docs/API.md".to_string()][..]) + ); + assert_eq!(ask.libraries[2].docs_paths(), None); + } + + #[test] + fn string_entry_roundtrips_as_plain_string() { + // The canonical no-override form must serialize back to a bare string, + // not an object — otherwise every ask.json would churn on write. + let entry = LibraryEntry::Spec("npm:next".into()); + assert_eq!(serde_json::to_string(&entry).unwrap(), "\"npm:next\""); + } + + #[test] + fn object_entry_uses_camelcase_docs_paths() { + let entry = entry_from_spec("npm:zod", &["docs/API.md".to_string()]); + assert_eq!( + serde_json::to_string(&entry).unwrap(), + r#"{"spec":"npm:zod","docsPaths":["docs/API.md"]}"# + ); + } + + #[test] + fn entry_from_spec_collapses_empty_docs_paths_to_string() { + assert_eq!( + entry_from_spec("npm:next", &[]), + LibraryEntry::Spec("npm:next".into()) + ); + assert!(matches!( + entry_from_spec("npm:next", &["d".to_string()]), + LibraryEntry::WithDocs(_) + )); + } + + #[test] + fn rejects_spec_without_ecosystem_prefix() { + let err = AskJson::parse(r#"{"libraries":["next"]}"#).unwrap_err(); + assert!(err.to_string().contains("ecosystem prefix")); + } + + #[test] + fn rejects_object_with_empty_docs_paths() { + // Structurally valid but violates the non-empty refinement. + let err = + AskJson::parse(r#"{"libraries":[{"spec":"npm:zod","docsPaths":[]}]}"#).unwrap_err(); + assert!(err.to_string().contains("non-empty")); + } + + #[test] + fn rejects_unknown_top_level_field() { + assert!(AskJson::parse(r#"{"libraries":[],"extra":1}"#).is_err()); + } + + #[test] + fn rejects_unknown_object_entry_field() { + assert!(AskJson::parse( + r#"{"libraries":[{"spec":"npm:zod","docsPaths":["d"],"ref":"v1"}]}"# + ) + .is_err()); + } + + #[test] + fn spec_string_shape_matches_regex() { + assert!(is_valid_spec_string("npm:next")); + assert!(is_valid_spec_string("github:owner/repo@v1.2.3")); + assert!(is_valid_spec_string("llms-txt:x")); + assert!(!is_valid_spec_string("Next:x")); // uppercase prefix start + assert!(!is_valid_spec_string("npm:")); // empty payload + assert!(!is_valid_spec_string("noprefix")); + assert!(!is_valid_spec_string(":x")); // empty prefix + } + + #[test] + fn store_mode_serializes_lowercase() { + assert_eq!(serde_json::to_string(&StoreMode::Ref).unwrap(), "\"ref\""); + assert_eq!( + serde_json::from_str::("\"link\"").unwrap(), + StoreMode::Link + ); + } +} diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 76e92cc..155cbcb 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -6,6 +6,7 @@ //! the `rust-port-20260704` track). Until a command is ported it reports a //! "not yet ported" notice via [`NotPorted`]. +pub mod ask_json; pub mod cli; pub mod spec; From e97823f337d3a4c12afcdeb82bb1ce061dff4335 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:33:07 +0900 Subject: [PATCH 07/44] feat(rust): port resolved.json cache schema (ResolvedEntry/ResolvedJson) Phase 3 of the Rust port. Ports packages/schema/src/resolved.ts: ResolvedEntry + ResolvedJson with Materialization (copy|link|ref|in-place) and EntryFormat (docs|intent-skills) kebab-case enums, optional fields omitted when None (skip_serializing_if), deny_unknown_fields (zod .strict()). validate() enforces the refinements serde can't: contentHash `sha256-<64hex>`, commit `<40hex>`, schemaVersion literal 1, and the superRefine rule (inPlacePath required when materialization is in-place). Datetime fields use a light RFC3339-with-offset shape check since the cache is machine-generated (a real date parser is deferred until a module actually constructs these). 10 tests. 33 tests green. --- crates/ask/src/lib.rs | 1 + crates/ask/src/resolved.rs | 310 +++++++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 crates/ask/src/resolved.rs diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 155cbcb..6a433c6 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -8,6 +8,7 @@ pub mod ask_json; pub mod cli; +pub mod resolved; pub mod spec; use std::fmt; diff --git a/crates/ask/src/resolved.rs b/crates/ask/src/resolved.rs new file mode 100644 index 0000000..6dd9cb5 --- /dev/null +++ b/crates/ask/src/resolved.rs @@ -0,0 +1,310 @@ +//! `.ask/resolved.json` schema — Rust port of `packages/schema/src/resolved.ts`. +//! +//! `resolved.json` is a pure cache that lets `ask install` short-circuit when +//! nothing has changed. `key` (the map key) is the library slug — the same +//! string used under `.ask/docs/@/` and as the skill dir name. +//! +//! This file is machine-generated (never hand-edited), so the datetime fields +//! use a light RFC3339 shape check rather than a full date parser; the content +//! hash, commit SHA, and structural refinements are validated faithfully. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// How the docs were materialized. `in-place` references +/// `node_modules//` directly; the others are global-store modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Materialization { + Copy, + Link, + Ref, + InPlace, +} + +/// Distinguishes materialized docs from intent-skills entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EntryFormat { + Docs, + IntentSkills, +} + +/// One row in `.ask/resolved.json`. Optional fields are omitted from the +/// serialized JSON when absent (parity with the TS optional keys). Unknown +/// fields are rejected (zod `.strict()`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResolvedEntry { + /// The exact spec from `ask.json` (so we can detect spec edits). + pub spec: String, + /// Resolved version (lockfile for PM-driven, `ref` for standalone). + pub resolved_version: String, + /// Hash of the materialized doc files (`sha256-<64 hex>`). + pub content_hash: String, + /// ISO timestamp of the most recent successful fetch. + pub fetched_at: String, + /// Number of files written under `.ask/docs/@/`. + pub file_count: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub store_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub materialization: Option, + /// Project-relative docs path when `materialization == in-place`. + #[serde(skip_serializing_if = "Option::is_none")] + pub in_place_path: Option, + /// Git commit SHA a `github` ref resolved to (40-char lowercase hex). + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + /// Relative subpath inside `store_path` holding the docs tree. + #[serde(skip_serializing_if = "Option::is_none")] + pub store_subpath: Option, +} + +/// The `.ask/resolved.json` document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResolvedJson { + pub schema_version: u32, + pub generated_at: String, + /// Slug → resolved entry. `BTreeMap` gives deterministic on-disk ordering + /// (the cache is regenerable, so sorted order is fine). + pub entries: BTreeMap, +} + +/// Validation failure for a resolved-cache document. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ResolvedError { + #[error("contentHash must be sha256-<64 hex chars>: {0:?}")] + ContentHash(String), + #[error("commit must be a 40-char lowercase hex SHA: {0:?}")] + Commit(String), + #[error("{field} must be an ISO-8601 datetime with offset: {value:?}")] + DateTime { field: &'static str, value: String }, + #[error("inPlacePath is required when materialization is 'in-place' (entry {0:?})")] + MissingInPlacePath(String), + #[error("schemaVersion must be 1, got {0}")] + SchemaVersion(u32), +} + +impl ResolvedEntry { + /// Enforce the field-shape refinements serde structural parsing cannot. + pub fn validate(&self) -> Result<(), ResolvedError> { + if !is_content_hash(&self.content_hash) { + return Err(ResolvedError::ContentHash(self.content_hash.clone())); + } + if !is_iso_datetime_offset(&self.fetched_at) { + return Err(ResolvedError::DateTime { + field: "fetchedAt", + value: self.fetched_at.clone(), + }); + } + if let Some(commit) = &self.commit { + if !is_git_sha(commit) { + return Err(ResolvedError::Commit(commit.clone())); + } + } + if self.materialization == Some(Materialization::InPlace) && self.in_place_path.is_none() { + return Err(ResolvedError::MissingInPlacePath(self.spec.clone())); + } + Ok(()) + } +} + +impl ResolvedJson { + /// Parse and validate a `resolved.json` document from a JSON string. + pub fn parse(json: &str) -> anyhow::Result { + let parsed: ResolvedJson = serde_json::from_str(json)?; + parsed.validate()?; + Ok(parsed) + } + + /// Validate the literal schema version, timestamp, and every entry. + pub fn validate(&self) -> Result<(), ResolvedError> { + if self.schema_version != 1 { + return Err(ResolvedError::SchemaVersion(self.schema_version)); + } + if !is_iso_datetime_offset(&self.generated_at) { + return Err(ResolvedError::DateTime { + field: "generatedAt", + value: self.generated_at.clone(), + }); + } + for entry in self.entries.values() { + entry.validate()?; + } + Ok(()) + } +} + +/// `^sha256-[0-9a-f]{64}$`. +fn is_content_hash(s: &str) -> bool { + match s.strip_prefix("sha256-") { + Some(hex) => { + hex.len() == 64 + && hex + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + } + None => false, + } +} + +/// `^[0-9a-f]{40}$`. +fn is_git_sha(s: &str) -> bool { + s.len() == 40 + && s.bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} + +/// Light RFC3339 shape check: `YYYY-MM-DDThh:mm:ss[.fff]` followed by `Z` or a +/// `±hh:mm` offset. Faithful to zod's `.datetime({ offset: true })` intent +/// (an offset is required) without pulling a date-parsing dependency; tightened +/// later if a module needs true calendar validation. +fn is_iso_datetime_offset(s: &str) -> bool { + let Some((date, rest)) = s.split_once('T') else { + return false; + }; + // Date: YYYY-MM-DD + let d: Vec<&str> = date.split('-').collect(); + if d.len() != 3 || d[0].len() != 4 || d[1].len() != 2 || d[2].len() != 2 { + return false; + } + if !d.iter().all(|p| p.bytes().all(|b| b.is_ascii_digit())) { + return false; + } + // Time + offset. + let (time, offset) = if let Some(stripped) = rest.strip_suffix('Z') { + (stripped, true) + } else if let Some(idx) = rest.rfind(['+', '-']) { + // Offset must be ±hh:mm. + let off = &rest[idx + 1..]; + let ok = off.len() == 5 + && off.as_bytes()[2] == b':' + && off[..2] + .bytes() + .chain(off[3..].bytes()) + .all(|b| b.is_ascii_digit()); + (&rest[..idx], ok) + } else { + (rest, false) + }; + if !offset { + return false; + } + // Time: hh:mm:ss with optional .fraction. + let core = time.split('.').next().unwrap_or(time); + let t: Vec<&str> = core.split(':').collect(); + t.len() == 3 + && t[0].len() == 2 + && t[1].len() == 2 + && t[2].len() == 2 + && t.iter().all(|p| p.bytes().all(|b| b.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const HASH: &str = "sha256-0000000000000000000000000000000000000000000000000000000000000000"; + const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + + fn base_entry_json(extra: &str) -> String { + format!( + r#"{{"spec":"npm:next","resolvedVersion":"15.0.0","contentHash":"{HASH}","fetchedAt":"2026-07-04T00:00:00Z","fileCount":3{extra}}}"# + ) + } + + fn doc(entry_json: &str) -> String { + format!( + r#"{{"schemaVersion":1,"generatedAt":"2026-07-04T00:00:00Z","entries":{{"next":{entry_json}}}}}"# + ) + } + + #[test] + fn parses_minimal_entry() { + let ask = ResolvedJson::parse(&doc(&base_entry_json(""))).unwrap(); + let e = &ask.entries["next"]; + assert_eq!(e.spec, "npm:next"); + assert_eq!(e.file_count, 3); + assert_eq!(e.commit, None); + } + + #[test] + fn optional_fields_omitted_when_none() { + let e = ResolvedJson::parse(&doc(&base_entry_json(""))) + .unwrap() + .entries + .remove("next") + .unwrap(); + let json = serde_json::to_string(&e).unwrap(); + assert!(!json.contains("commit")); + assert!(!json.contains("storePath")); + assert!(!json.contains("format")); + } + + #[test] + fn kebab_case_enums_roundtrip() { + let json = base_entry_json( + r#","materialization":"in-place","inPlacePath":"node_modules/next/dist/docs","format":"intent-skills""#, + ); + let e: ResolvedEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(e.materialization, Some(Materialization::InPlace)); + assert_eq!(e.format, Some(EntryFormat::IntentSkills)); + // Re-serialize keeps the kebab-case wire form. + let out = serde_json::to_string(&e).unwrap(); + assert!(out.contains(r#""materialization":"in-place""#)); + assert!(out.contains(r#""format":"intent-skills""#)); + } + + #[test] + fn in_place_requires_in_place_path() { + let json = base_entry_json(r#","materialization":"in-place""#); + let err = ResolvedJson::parse(&doc(&json)).unwrap_err(); + assert!(err.to_string().contains("inPlacePath is required")); + } + + #[test] + fn rejects_bad_content_hash() { + let json = base_entry_json("").replace(HASH, "sha256-xyz"); + assert!(ResolvedJson::parse(&doc(&json)).is_err()); + } + + #[test] + fn rejects_bad_commit_sha() { + let json = base_entry_json(r#","commit":"NOTASHA""#); + assert!(ResolvedJson::parse(&doc(&json)).is_err()); + } + + #[test] + fn accepts_valid_commit_sha() { + let json = base_entry_json(&format!(r#","commit":"{SHA}""#)); + let ask = ResolvedJson::parse(&doc(&json)).unwrap(); + assert_eq!(ask.entries["next"].commit.as_deref(), Some(SHA)); + } + + #[test] + fn rejects_wrong_schema_version() { + let doc = doc(&base_entry_json("")).replace("\"schemaVersion\":1", "\"schemaVersion\":2"); + assert!(ResolvedJson::parse(&doc).is_err()); + } + + #[test] + fn rejects_unknown_entry_field() { + let json = base_entry_json(r#","bogus":1"#); + assert!(ResolvedJson::parse(&doc(&json)).is_err()); + } + + #[test] + fn datetime_offset_shapes() { + assert!(is_iso_datetime_offset("2026-07-04T00:00:00Z")); + assert!(is_iso_datetime_offset("2026-07-04T12:30:59.123+09:00")); + assert!(is_iso_datetime_offset("2026-07-04T12:30:59-05:00")); + assert!(!is_iso_datetime_offset("2026-07-04T00:00:00")); // no offset + assert!(!is_iso_datetime_offset("2026-07-04 00:00:00Z")); // no T + assert!(!is_iso_datetime_offset("not-a-date")); + } +} From 23f9aee85beb7f8a9d99b36f4253a1f4907f155d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:36:18 +0900 Subject: [PATCH 08/44] feat(rust): port io module (ask.json/resolved.json I/O + content hashing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports packages/cli/src/io.ts: sorted_json (deterministic key-sorted JSON via serde_json::Value normalization — the Map is a BTreeMap by default, giving the TS sortKeys byte-for-byte output), content_hash (sha256 over NUL-separated \0\0, order-independent), path helpers, find_entry (3-way match), read/write ask.json + resolved.json, upsert/remove resolved entries with the fetched_at-insensitive change skip. Also ports split_explicit_version into spec.rs (scope-marker vs version-sep disambiguation). Adds sha2 + time (RFC3339 now/format) deps. read_resolved_json falls back to the empty cache on any parse failure (safe-to-delete contract). 11 tests incl. tempdir roundtrips + a pinned sha256 vector. 44 tests green. --- Cargo.lock | 121 +++++++++++++ Cargo.toml | 1 + crates/ask/Cargo.toml | 2 + crates/ask/src/io.rs | 389 +++++++++++++++++++++++++++++++++++++++++ crates/ask/src/lib.rs | 1 + crates/ask/src/spec.rs | 42 +++++ 6 files changed, 556 insertions(+) create mode 100644 crates/ask/src/io.rs diff --git a/Cargo.lock b/Cargo.lock index 6b7e317..e268b98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,8 +66,10 @@ dependencies = [ "clap", "serde", "serde_json", + "sha2", "tempfile", "thiserror", + "time", ] [[package]] @@ -76,6 +78,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[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 = "cfg-if" version = "1.0.4" @@ -128,6 +139,41 @@ 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 = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[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 = "errno" version = "0.3.14" @@ -144,6 +190,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[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.4.3" @@ -191,6 +247,12 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -203,6 +265,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -283,6 +351,17 @@ dependencies = [ "zmij", ] +[[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" @@ -333,6 +412,42 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[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" @@ -345,6 +460,12 @@ 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 = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index b250b11..770b46b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ regex = "1" fancy-regex = "0.16" semver = "1" sha2 = "0.10" +time = { version = "0.3", features = ["formatting", "parsing"] } tempfile = "3" walkdir = "2" ignore = "0.4" diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml index 6596fe9..d6b54e8 100644 --- a/crates/ask/Cargo.toml +++ b/crates/ask/Cargo.toml @@ -31,6 +31,8 @@ anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +sha2 = { workspace = true } +time = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/ask/src/io.rs b/crates/ask/src/io.rs new file mode 100644 index 0000000..f499f2b --- /dev/null +++ b/crates/ask/src/io.rs @@ -0,0 +1,389 @@ +//! `ask.json` / `.ask/resolved.json` file I/O + hashing — Rust port of +//! `packages/cli/src/io.ts`. +//! +//! `ask.json` is a root-level file (beside `package.json`); `.ask/` holds +//! materialized, gitignored output (docs + the resolved cache). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +use crate::ask_json::{AskJson, LibraryEntry}; +use crate::resolved::{ResolvedEntry, ResolvedJson}; +use crate::spec::{library_name_from_spec, parse_spec, split_explicit_version, ParsedSpec}; + +const ASK_DIR: &str = ".ask"; +const ASK_JSON_FILE: &str = "ask.json"; +const RESOLVED_FILE: &str = "resolved.json"; + +/// Serialize a value to JSON with **recursively sorted keys**, 2-space indent, +/// and a trailing newline. Two calls with semantically equivalent input always +/// produce the same byte string (port of `sortedJSON`). +/// +/// Routing through `serde_json::Value` normalizes key order: serde_json's `Map` +/// is a `BTreeMap` by default (the `preserve_order` feature is off), so +/// `to_value` yields sorted maps at every level and arrays keep their order — +/// exactly the TS `sortKeys` contract. +pub fn sorted_json(value: &T) -> String { + let normalized = serde_json::to_value(value).expect("serializable value"); + let mut out = serde_json::to_string_pretty(&normalized).expect("serializable value"); + out.push('\n'); + out +} + +/// A file participating in a content hash. Exactly one of `bytes`/`content` is +/// used (bytes wins); an absent payload hashes as empty, matching the TS. +pub struct HashableFile { + pub relpath: String, + pub bytes: Option>, + pub content: Option, +} + +impl HashableFile { + fn payload(&self) -> &[u8] { + if let Some(b) = &self.bytes { + b + } else if let Some(c) = &self.content { + c.as_bytes() + } else { + &[] + } + } +} + +/// Deterministic content hash over a list of files. Files are sorted by relative +/// path; each contributes `\0\0` to the stream. The NUL +/// separators prevent `path + content` ambiguity (`"ab"+"cd"` vs `"a"+"bcd"`). +/// Returns `sha256-` (port of `contentHash`). +pub fn content_hash(files: &[HashableFile]) -> String { + let mut sorted: Vec<&HashableFile> = files.iter().collect(); + sorted.sort_by(|a, b| a.relpath.cmp(&b.relpath)); + let mut hash = Sha256::new(); + for f in sorted { + hash.update(f.relpath.as_bytes()); + hash.update([0u8]); + hash.update(f.payload()); + hash.update([0u8]); + } + format!("sha256-{:x}", hash.finalize()) +} + +pub fn get_ask_dir(project_dir: &Path) -> PathBuf { + project_dir.join(ASK_DIR) +} + +/// `ask.json` sits at the project root, NOT inside `.ask/`. +pub fn get_ask_json_path(project_dir: &Path) -> PathBuf { + project_dir.join(ASK_JSON_FILE) +} + +pub fn get_resolved_json_path(project_dir: &Path) -> PathBuf { + get_ask_dir(project_dir).join(RESOLVED_FILE) +} + +/// Locate a library entry in `ask.json` by user-supplied identifier. Matches in +/// three steps (parity with `findEntry` / `ask remove`): +/// +/// 1. Exact spec string. +/// 2. Library name via `library_name_from_spec(split_explicit_version(...))` +/// — so `next` matches both `npm:next` and `npm:next@14`. +/// 3. Raw npm package name — `next` → `npm:next`, `@scope/pkg` → `npm:@scope/pkg`. +/// +/// Returns `None` when nothing matches so callers can fall through to defaults. +pub fn find_entry<'a>(ask_json: &'a AskJson, target: &str) -> Option<&'a LibraryEntry> { + for entry in &ask_json.libraries { + let spec = entry.spec(); + if spec == target { + return Some(entry); + } + let (body, _version) = split_explicit_version(spec); + if library_name_from_spec(body) == target { + return Some(entry); + } + if let ParsedSpec::Npm { pkg, .. } = parse_spec(body) { + if pkg == target { + return Some(entry); + } + } + } + None +} + +/// Read and validate `ask.json`. Returns `Ok(None)` when the file does not +/// exist (so the install orchestrator can bootstrap an empty file). Errors on +/// invalid JSON or schema violations. +pub fn read_ask_json(project_dir: &Path) -> anyhow::Result> { + let file = get_ask_json_path(project_dir); + if !file.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(&file)?; + let ask = AskJson::parse(&raw) + .map_err(|e| anyhow::anyhow!("Failed to parse {}: {e}.", file.display()))?; + Ok(Some(ask)) +} + +/// Validate and write `ask.json`. Library entries are NOT reordered (users may +/// care about declaration order; `ask add` appends). Only object *keys* within +/// each entry are sorted, via `sorted_json`. +pub fn write_ask_json(project_dir: &Path, ask_json: &AskJson) -> anyhow::Result<()> { + ask_json.validate()?; + let file = get_ask_json_path(project_dir); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&file, sorted_json(ask_json))?; + Ok(()) +} + +/// Read and validate `.ask/resolved.json`. Returns the default empty cache when +/// the file is missing OR fails to parse/validate — the cache is rebuilt from +/// scratch in that case. This is the contract that makes `resolved.json` safe to +/// delete by hand. +pub fn read_resolved_json(project_dir: &Path) -> ResolvedJson { + let file = get_resolved_json_path(project_dir); + let Ok(raw) = std::fs::read_to_string(&file) else { + return empty_resolved(); + }; + ResolvedJson::parse(&raw).unwrap_or_else(|_| empty_resolved()) +} + +/// The default empty resolved cache. +pub fn empty_resolved() -> ResolvedJson { + ResolvedJson { + schema_version: 1, + generated_at: "1970-01-01T00:00:00Z".to_string(), + entries: BTreeMap::new(), + } +} + +/// Validate and write `.ask/resolved.json`. +pub fn write_resolved_json(project_dir: &Path, resolved: &ResolvedJson) -> anyhow::Result<()> { + resolved.validate()?; + let file = get_resolved_json_path(project_dir); + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&file, sorted_json(resolved))?; + Ok(()) +} + +/// Upsert a single entry into `.ask/resolved.json`. Skips the rewrite when +/// nothing changed (modulo `fetchedAt`) so file watchers stay quiet on no-op +/// runs (parity with `upsertResolvedEntry`). +pub fn upsert_resolved_entry( + project_dir: &Path, + key: &str, + entry: ResolvedEntry, +) -> anyhow::Result<()> { + let mut resolved = read_resolved_json(project_dir); + let changed = match resolved.entries.get(key) { + None => true, + Some(previous) => !eq_ignoring_fetched_at(previous, &entry), + }; + if !changed { + return Ok(()); + } + resolved.entries.insert(key.to_string(), entry); + resolved.generated_at = now_iso(); + write_resolved_json(project_dir, &resolved) +} + +/// Remove one or more entries from `.ask/resolved.json` by key. No-op if the +/// key set is empty (parity with `removeResolvedEntries`). +pub fn remove_resolved_entries(project_dir: &Path, keys: &[String]) -> anyhow::Result<()> { + if keys.is_empty() { + return Ok(()); + } + let mut resolved = read_resolved_json(project_dir); + for k in keys { + resolved.entries.remove(k); + } + resolved.generated_at = now_iso(); + write_resolved_json(project_dir, &resolved) +} + +/// Two entries are equal ignoring `fetched_at` (the TS `stripFetchedAt` +/// comparison, done structurally instead of via JSON). +fn eq_ignoring_fetched_at(a: &ResolvedEntry, b: &ResolvedEntry) -> bool { + let mut a = a.clone(); + a.fetched_at = b.fetched_at.clone(); + &a == b +} + +/// Current UTC time as an RFC3339 string (`new Date().toISOString()` analog). +fn now_iso() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .expect("Rfc3339 formatting of now_utc is infallible") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ask_json::entry_from_spec; + + fn tmp() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + #[test] + fn sorted_json_sorts_keys_recursively_with_trailing_newline() { + let value = serde_json::json!({ "b": 1, "a": { "d": 2, "c": 3 } }); + let out = sorted_json(&value); + assert_eq!( + out, + "{\n \"a\": {\n \"c\": 3,\n \"d\": 2\n },\n \"b\": 1\n}\n" + ); + } + + #[test] + fn content_hash_is_order_independent_and_nul_separated() { + let f = |p: &str, c: &str| HashableFile { + relpath: p.into(), + bytes: None, + content: Some(c.into()), + }; + let a = content_hash(&[f("a.md", "x"), f("b.md", "y")]); + let b = content_hash(&[f("b.md", "y"), f("a.md", "x")]); + assert_eq!(a, b, "sorting by relpath makes order irrelevant"); + assert!(a.starts_with("sha256-")); + assert_eq!(a.len(), "sha256-".len() + 64); + // NUL separation: ("ab","cd") must differ from ("a","bcd"). + assert_ne!( + content_hash(&[f("ab", "cd")]), + content_hash(&[f("a", "bcd")]) + ); + } + + #[test] + fn content_hash_matches_known_vector() { + // sha256 of "a.md\0hello\0" — pin the exact stream format. + let h = content_hash(&[HashableFile { + relpath: "a.md".into(), + bytes: None, + content: Some("hello".into()), + }]); + let mut hasher = Sha256::new(); + hasher.update(b"a.md\0hello\0"); + assert_eq!(h, format!("sha256-{:x}", hasher.finalize())); + } + + #[test] + fn read_missing_ask_json_is_none() { + let dir = tmp(); + assert!(read_ask_json(dir.path()).unwrap().is_none()); + } + + #[test] + fn write_then_read_ask_json_roundtrips() { + let dir = tmp(); + let ask = AskJson { + libraries: vec![ + LibraryEntry::Spec("npm:next".into()), + entry_from_spec("npm:zod", &["docs/API.md".to_string()]), + ], + }; + write_ask_json(dir.path(), &ask).unwrap(); + // ask.json is at the root, not under .ask/. + assert!(dir.path().join("ask.json").exists()); + let back = read_ask_json(dir.path()).unwrap().unwrap(); + assert_eq!(back, ask); + } + + #[test] + fn find_entry_three_ways() { + let ask = AskJson { + libraries: vec![ + LibraryEntry::Spec("npm:next".into()), + LibraryEntry::Spec("npm:@mastra/client-js".into()), + LibraryEntry::Spec("github:vercel/next.js@v14.2.3".into()), + ], + }; + // 1. exact spec + assert_eq!(find_entry(&ask, "npm:next").unwrap().spec(), "npm:next"); + // 2. library name (with version stripped) + assert_eq!( + find_entry(&ask, "next.js").unwrap().spec(), + "github:vercel/next.js@v14.2.3" + ); + // 3. raw npm package name (scoped) + assert_eq!( + find_entry(&ask, "@mastra/client-js").unwrap().spec(), + "npm:@mastra/client-js" + ); + assert!(find_entry(&ask, "nonexistent").is_none()); + } + + #[test] + fn read_missing_resolved_is_empty_default() { + let dir = tmp(); + let r = read_resolved_json(dir.path()); + assert_eq!(r.schema_version, 1); + assert!(r.entries.is_empty()); + } + + #[test] + fn corrupt_resolved_json_falls_back_to_empty() { + let dir = tmp(); + let file = get_resolved_json_path(dir.path()); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "{ not valid json").unwrap(); + assert!(read_resolved_json(dir.path()).entries.is_empty()); + } + + fn sample_entry(spec: &str) -> ResolvedEntry { + ResolvedEntry { + spec: spec.into(), + resolved_version: "1.0.0".into(), + content_hash: format!("sha256-{}", "0".repeat(64)), + fetched_at: "2026-07-04T00:00:00Z".into(), + file_count: 1, + format: None, + store_path: None, + materialization: None, + in_place_path: None, + commit: None, + store_subpath: None, + } + } + + #[test] + fn upsert_then_remove_resolved_entry() { + let dir = tmp(); + upsert_resolved_entry(dir.path(), "next", sample_entry("npm:next")).unwrap(); + let r = read_resolved_json(dir.path()); + assert!(r.entries.contains_key("next")); + // generatedAt was stamped to a real time (not the 1970 default). + assert_ne!(r.generated_at, "1970-01-01T00:00:00Z"); + + remove_resolved_entries(dir.path(), &["next".to_string()]).unwrap(); + assert!(!read_resolved_json(dir.path()).entries.contains_key("next")); + } + + #[test] + fn upsert_skips_rewrite_when_only_fetched_at_differs() { + let dir = tmp(); + upsert_resolved_entry(dir.path(), "next", sample_entry("npm:next")).unwrap(); + let first = read_resolved_json(dir.path()).generated_at.clone(); + + // Same entry but a different fetched_at → treated as unchanged, no rewrite. + let mut again = sample_entry("npm:next"); + again.fetched_at = "2099-01-01T00:00:00Z".into(); + upsert_resolved_entry(dir.path(), "next", again).unwrap(); + assert_eq!(read_resolved_json(dir.path()).generated_at, first); + + // A material change (version) → rewrite. + let mut changed = sample_entry("npm:next"); + changed.resolved_version = "2.0.0".into(); + upsert_resolved_entry(dir.path(), "next", changed).unwrap(); + assert_eq!( + read_resolved_json(dir.path()).entries["next"].resolved_version, + "2.0.0" + ); + } +} diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 6a433c6..97fd7e7 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -8,6 +8,7 @@ pub mod ask_json; pub mod cli; +pub mod io; pub mod resolved; pub mod spec; diff --git a/crates/ask/src/spec.rs b/crates/ask/src/spec.rs index ea2279f..dba2427 100644 --- a/crates/ask/src/spec.rs +++ b/crates/ask/src/spec.rs @@ -95,6 +95,23 @@ pub fn library_name_from_spec(spec: &str) -> String { parse_spec(spec).name().to_string() } +/// Split an inline `@version` suffix off a spec — port of +/// `splitExplicitVersion` in `commands/ensure-checkout.ts`. +/// +/// The `@` is treated as a version separator only when it is neither the first +/// character (a bare scoped name `@vercel/ai`) nor immediately after the +/// ecosystem-prefix colon (`npm:@vercel/ai`). Returns the spec body and the +/// optional version, borrowing from the input. +pub fn split_explicit_version(input: &str) -> (&str, Option<&str>) { + match input.rfind('@') { + None => (input, None), + // `@` at index 0, or right after a `:` → scope marker, not a version. + Some(0) => (input, None), + Some(idx) if input.as_bytes()[idx - 1] == b':' => (input, None), + Some(idx) => (&input[..idx], Some(&input[idx + 1..])), + } +} + /// `@mastra/client-js` → `mastra-client-js`. Scoped npm names are not valid as /// `.ask/docs/` or Claude Code skill dir names, so we flatten them the same /// way the registry server does. Non-scoped names pass through unchanged. @@ -219,6 +236,31 @@ mod tests { assert_eq!(library_name_from_spec("react"), "react"); } + #[test] + fn split_explicit_version_cases() { + assert_eq!( + split_explicit_version("npm:next@14"), + ("npm:next", Some("14")) + ); + assert_eq!( + split_explicit_version("github:vercel/next.js@v14.2.3"), + ("github:vercel/next.js", Some("v14.2.3")) + ); + // Bare scoped name: leading `@` is a scope marker. + assert_eq!(split_explicit_version("@vercel/ai"), ("@vercel/ai", None)); + // `@` right after the ecosystem colon is a scope marker. + assert_eq!( + split_explicit_version("npm:@vercel/ai"), + ("npm:@vercel/ai", None) + ); + // Scoped name WITH a version → split on the last `@`. + assert_eq!( + split_explicit_version("npm:@vercel/ai@5.0.0"), + ("npm:@vercel/ai", Some("5.0.0")) + ); + assert_eq!(split_explicit_version("next"), ("next", None)); + } + #[test] fn slugify_edge_cases() { assert_eq!(slugify_npm_name("next"), "next"); From 5a7466151c2c18de50692a51beafe2c99cf261ca Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:37:42 +0900 Subject: [PATCH 09/44] feat(rust): port markers module (marker-block inject/remove/has) Phase 3 of the Rust port. Ports packages/cli/src/markers.ts: MarkerSyntax {Html,Hash}, wrap/inject/remove/has. inject replaces an existing block in place or appends with a blank-line separator (idempotent); remove normalizes the surrounding blank lines. Marker delimiters are ASCII so str::find byte offsets stay on char boundaries around multi-byte user content. 10 tests cover the append/replace/idempotency and the blank-line normalization cases. 54 tests green. --- crates/ask/src/lib.rs | 1 + crates/ask/src/markers.rs | 187 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 crates/ask/src/markers.rs diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 97fd7e7..5d71bd4 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -9,6 +9,7 @@ pub mod ask_json; pub mod cli; pub mod io; +pub mod markers; pub mod resolved; pub mod spec; diff --git a/crates/ask/src/markers.rs b/crates/ask/src/markers.rs new file mode 100644 index 0000000..043a624 --- /dev/null +++ b/crates/ask/src/markers.rs @@ -0,0 +1,187 @@ +//! Marker-block helpers — Rust port of `packages/cli/src/markers.ts`. +//! +//! Injects ASK-owned content into user files without touching the surrounding +//! user content. Two comment syntaxes: +//! - [`MarkerSyntax::Html`] for Markdown (``) +//! - [`MarkerSyntax::Hash`] for ignore/properties files (`# ask:start … # ask:end`) +//! +//! Marker delimiters are ASCII, so `str::find` byte offsets always land on char +//! boundaries even when the wrapped user content is multi-byte UTF-8. + +/// Which comment syntax delimits the marker block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarkerSyntax { + Html, + Hash, +} + +impl MarkerSyntax { + fn begin(self) -> &'static str { + match self { + MarkerSyntax::Html => "", + MarkerSyntax::Hash => "# ask:start", + } + } + + fn end(self) -> &'static str { + match self { + MarkerSyntax::Html => "", + MarkerSyntax::Hash => "# ask:end", + } + } +} + +/// Wrap a payload with begin/end markers. No trailing newline (parity with +/// `wrap`). +pub fn wrap(payload: &str, syntax: MarkerSyntax) -> String { + format!("{}\n{}\n{}", syntax.begin(), payload, syntax.end()) +} + +/// Locate a well-formed marker block: both delimiters present and `end` after +/// `begin`. Returns the byte offsets `(begin_idx, end_idx)`. +fn locate(content: &str, syntax: MarkerSyntax) -> Option<(usize, usize)> { + let begin_idx = content.find(syntax.begin())?; + let end_idx = content.find(syntax.end())?; + if end_idx > begin_idx { + Some((begin_idx, end_idx)) + } else { + None + } +} + +/// Inject or refresh a marker block. If a matching block exists it is replaced +/// in place; otherwise the block is appended with a blank-line separator. +/// Deterministic and idempotent (parity with `inject`). +pub fn inject(content: &str, block: &str, syntax: MarkerSyntax) -> String { + let end = syntax.end(); + if let Some((begin_idx, end_idx)) = locate(content, syntax) { + // Replace existing block in place. + let mut out = String::with_capacity(content.len()); + out.push_str(&content[..begin_idx]); + out.push_str(block); + out.push_str(&content[end_idx + end.len()..]); + return out; + } + + if content.is_empty() { + return format!("{block}\n"); + } + format!("{}\n\n{}\n", content.trim_end(), block) +} + +/// Strip the marker block if present, normalizing the blank lines around the +/// removed region. Returns the content unchanged if no block is found (parity +/// with `remove`). +pub fn remove(content: &str, syntax: MarkerSyntax) -> String { + let end = syntax.end(); + let Some((begin_idx, end_idx)) = locate(content, syntax) else { + return content.to_string(); + }; + let before = content[..begin_idx].trim_end(); + let after = content[end_idx + end.len()..].trim_start(); + + if before.is_empty() && after.is_empty() { + return String::new(); + } + if before.is_empty() { + return if after.ends_with('\n') { + after.to_string() + } else { + format!("{after}\n") + }; + } + if after.is_empty() { + return format!("{before}\n"); + } + let tail = if after.ends_with('\n') { + after.to_string() + } else { + format!("{after}\n") + }; + format!("{before}\n\n{tail}") +} + +/// Whether a well-formed marker block of the given syntax exists. +pub fn has(content: &str, syntax: MarkerSyntax) -> bool { + locate(content, syntax).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use MarkerSyntax::{Hash, Html}; + + #[test] + fn wrap_has_no_trailing_newline() { + assert_eq!( + wrap("body", Html), + "\nbody\n" + ); + assert_eq!(wrap("body", Hash), "# ask:start\nbody\n# ask:end"); + } + + #[test] + fn inject_into_empty_appends_single_trailing_newline() { + let block = wrap("x", Html); + assert_eq!(inject("", &block, Html), format!("{block}\n")); + } + + #[test] + fn inject_appends_with_blank_line_separator() { + let block = wrap("x", Html); + let out = inject("# Title\n", &block, Html); + assert_eq!(out, format!("# Title\n\n{block}\n")); + } + + #[test] + fn inject_is_idempotent_and_replaces_in_place() { + let first = inject("# Title\n", &wrap("v1", Html), Html); + let second = inject(&first, &wrap("v2", Html), Html); + // The v1 block is replaced by v2, not duplicated. + assert_eq!(second.matches("ask:start").count(), 1); + assert!(second.contains("v2")); + assert!(!second.contains("v1")); + // Re-injecting the same block yields identical output (idempotent). + assert_eq!(inject(&second, &wrap("v2", Html), Html), second); + } + + #[test] + fn has_detects_well_formed_block_only() { + let with = inject("doc", &wrap("x", Hash), Hash); + assert!(has(&with, Hash)); + assert!(!has("no markers here", Hash)); + // end-before-begin is not a well-formed block. + assert!(!has("# ask:end\n# ask:start", Hash)); + } + + #[test] + fn remove_block_only_leaves_empty_string() { + let only = wrap("x", Html); + assert_eq!(remove(&only, Html), ""); + } + + #[test] + fn remove_preserves_surrounding_content() { + let content = inject("# Title\n", &wrap("x", Html), Html); + assert_eq!(remove(&content, Html), "# Title\n"); + } + + #[test] + fn remove_between_two_user_sections_normalizes_blank_lines() { + let block = wrap("x", Hash); + let content = format!("before\n\n{block}\n\nafter\n"); + assert_eq!(remove(&content, Hash), "before\n\nafter\n"); + } + + #[test] + fn remove_absent_block_is_noop() { + assert_eq!(remove("untouched\n", Html), "untouched\n"); + } + + #[test] + fn inject_remove_roundtrip_returns_original_trimmed() { + let original = "# Title\n"; + let injected = inject(original, &wrap("x", Html), Html); + assert_eq!(remove(&injected, Html), "# Title\n"); + } +} From 4148ea0eaa3a61775bd857aa1f6e5515b64ab062 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:39:47 +0900 Subject: [PATCH 10/44] feat(rust): port registry spec parsing + ecosystem detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports the pure part of packages/cli/src/registry.ts: parse_ecosystem, parse_doc_spec (ParsedDocSpec {Github|Ecosystem|Name} with the github/ecosystem/name disambiguation and exact error messages via DocSpecError), split_name_version, detect_ecosystem (marker-file → ecosystem, npm default). The HTTP surface (fetchRegistryEntry/resolveFromRegistry, 10s-timeout fetch) + RegistrySource type are deferred until the registry schema + HTTP client land (noted in the module doc). 5 tests. 59 tests green. --- crates/ask/src/lib.rs | 1 + crates/ask/src/registry.rs | 279 +++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 crates/ask/src/registry.rs diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 5d71bd4..240fc3e 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -10,6 +10,7 @@ pub mod ask_json; pub mod cli; pub mod io; pub mod markers; +pub mod registry; pub mod resolved; pub mod spec; diff --git a/crates/ask/src/registry.rs b/crates/ask/src/registry.rs new file mode 100644 index 0000000..2118f42 --- /dev/null +++ b/crates/ask/src/registry.rs @@ -0,0 +1,279 @@ +//! Registry spec parsing + ecosystem detection — Rust port of the pure parts of +//! `packages/cli/src/registry.ts`. +//! +//! The HTTP surface (`fetchRegistryEntry` / `resolveFromRegistry`, bounded by a +//! 10s timeout against `https://ask-registry.pages.dev`) and the `RegistrySource` +//! response type are deferred to a later phase — they depend on the registry +//! schema (`@pleaseai/ask-schema`) and an HTTP client, ported together once the +//! registry-entry schema lands. The parsing/detection helpers here are pure and +//! unblock the resolver + sources layers. + +use std::path::Path; + +/// The base URL for the ASK registry API. Kept here so the HTTP port reuses it. +pub const REGISTRY_BASE_URL: &str = "https://ask-registry.pages.dev"; + +/// Split an ecosystem prefix off a spec (port of `parseEcosystem`). +/// +/// `npm:next@canary` → `(Some("npm"), "next@canary")`; `next@canary` → +/// `(None, "next@canary")`. A prefix is the text before the first `:`, but only +/// when that colon comes before any `/` — this rules out `owner/repo` shorthand +/// while still accepting scoped names like `npm:@mastra/client-js`. +pub fn parse_ecosystem(input: &str) -> (Option<&str>, &str) { + let colon = input.find(':'); + let slash = input.find('/'); + if let Some(c) = colon { + // `colonIdx > 0` in TS: a leading colon does not count as a prefix. + if c > 0 && slash.is_none_or(|s| c < s) { + return (Some(&input[..c]), &input[c + 1..]); + } + } + (None, input) +} + +/// A parsed `docs add` identifier (port of `ParsedDocSpec`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParsedDocSpec { + Github { + owner: String, + repo: String, + ref_: Option, + }, + Ecosystem { + ecosystem: String, + name: String, + version: String, + }, + Name { + name: String, + version: String, + }, +} + +/// Error from [`parse_doc_spec`] — mirrors the TS thrown-error messages. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum DocSpecError { + #[error("docs spec is empty — expected `owner/repo`, `ecosystem:name`, or `name`")] + Empty, + #[error( + "invalid docs spec '{0}': github shorthand must contain exactly one slash (owner/repo)" + )] + NotExactlyOneSlash(String), + #[error("invalid docs spec '{0}': owner segment is empty")] + EmptyOwner(String), + #[error("invalid docs spec '{0}': repo segment is empty")] + EmptyRepo(String), +} + +/// Split `name[@version]`, defaulting the version to `latest`. `lastAt > 0` +/// keeps a leading `@` (bare scoped name) as part of the name. +fn split_name_version(spec: &str) -> (String, String) { + match spec.rfind('@') { + Some(idx) if idx > 0 => (spec[..idx].to_string(), spec[idx + 1..].to_string()), + _ => (spec.to_string(), "latest".to_string()), + } +} + +/// Parse a `docs add` identifier into a [`ParsedDocSpec`] (port of +/// `parseDocSpec`). Disambiguation, checked in order: +/// +/// 1. Contains `/` and no `:` → github (`owner/repo[@ref]`, exactly one slash). +/// 2. Contains `:` (non-empty prefix) → ecosystem (`prefix:name[@version]`). +/// 3. Otherwise → bare name (`name[@version]`). +pub fn parse_doc_spec(input: &str) -> Result { + if input.is_empty() { + return Err(DocSpecError::Empty); + } + + // 1. github shape: owner/repo[@ref]. The `:` exclusion keeps scoped + // ecosystem specs (`npm:@scope/pkg@1.0`) out of the github branch. + if input.contains('/') && !input.contains(':') { + let parts: Vec<&str> = input.split('/').collect(); + if parts.len() != 2 { + return Err(DocSpecError::NotExactlyOneSlash(input.to_string())); + } + let owner = parts[0]; + let repo_and_ref = parts[1]; + if owner.is_empty() { + return Err(DocSpecError::EmptyOwner(input.to_string())); + } + if repo_and_ref.is_empty() { + return Err(DocSpecError::EmptyRepo(input.to_string())); + } + if let Some(at) = repo_and_ref.find('@') { + let repo = &repo_and_ref[..at]; + let ref_ = &repo_and_ref[at + 1..]; + if repo.is_empty() { + return Err(DocSpecError::EmptyRepo(input.to_string())); + } + return Ok(ParsedDocSpec::Github { + owner: owner.to_string(), + repo: repo.to_string(), + // An empty ref (`owner/repo@`) drops back to no ref. + ref_: (!ref_.is_empty()).then(|| ref_.to_string()), + }); + } + return Ok(ParsedDocSpec::Github { + owner: owner.to_string(), + repo: repo_and_ref.to_string(), + ref_: None, + }); + } + + // 2. ecosystem shape: prefix:name[@version]. + if let Some(colon) = input.find(':') { + if colon > 0 { + let ecosystem = &input[..colon]; + let (name, version) = split_name_version(&input[colon + 1..]); + return Ok(ParsedDocSpec::Ecosystem { + ecosystem: ecosystem.to_string(), + name, + version, + }); + } + } + + // 3. bare name: name[@version]. + let (name, version) = split_name_version(input); + Ok(ParsedDocSpec::Name { name, version }) +} + +/// Detect the project ecosystem from marker files, defaulting to `npm` (port of +/// `detectEcosystem`). Checks are ordered; the first existing file wins. +pub fn detect_ecosystem(project_dir: &Path) -> &'static str { + const CHECKS: &[(&str, &str)] = &[ + ("package.json", "npm"), + ("pubspec.yaml", "pub"), + ("pyproject.toml", "pypi"), + ("requirements.txt", "pypi"), + ("go.mod", "go"), + ("Cargo.toml", "crates"), + ("mix.exs", "hex"), + ("pom.xml", "maven"), + ("build.gradle", "maven"), + ("build.gradle.kts", "maven"), + ]; + for (file, ecosystem) in CHECKS { + if project_dir.join(file).exists() { + return ecosystem; + } + } + "npm" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ecosystem_cases() { + assert_eq!( + parse_ecosystem("npm:next@canary"), + (Some("npm"), "next@canary") + ); + assert_eq!(parse_ecosystem("next@canary"), (None, "next@canary")); + // Scoped npm name: colon before slash → still an ecosystem prefix. + assert_eq!( + parse_ecosystem("npm:@mastra/client-js"), + (Some("npm"), "@mastra/client-js") + ); + // owner/repo shorthand: slash before colon (or no colon) → no prefix. + assert_eq!(parse_ecosystem("owner/repo"), (None, "owner/repo")); + // Leading colon does not count as a prefix. + assert_eq!(parse_ecosystem(":x"), (None, ":x")); + } + + #[test] + fn parse_doc_spec_github_shapes() { + assert_eq!( + parse_doc_spec("vercel/next.js").unwrap(), + ParsedDocSpec::Github { + owner: "vercel".into(), + repo: "next.js".into(), + ref_: None + } + ); + assert_eq!( + parse_doc_spec("vercel/next.js@v14").unwrap(), + ParsedDocSpec::Github { + owner: "vercel".into(), + repo: "next.js".into(), + ref_: Some("v14".into()), + } + ); + // Trailing `@` drops back to no ref. + assert_eq!( + parse_doc_spec("vercel/next.js@").unwrap(), + ParsedDocSpec::Github { + owner: "vercel".into(), + repo: "next.js".into(), + ref_: None + } + ); + } + + #[test] + fn parse_doc_spec_github_errors() { + assert_eq!(parse_doc_spec("").unwrap_err(), DocSpecError::Empty); + assert_eq!( + parse_doc_spec("a/b/c").unwrap_err(), + DocSpecError::NotExactlyOneSlash("a/b/c".into()) + ); + assert_eq!( + parse_doc_spec("/repo").unwrap_err(), + DocSpecError::EmptyOwner("/repo".into()) + ); + assert_eq!( + parse_doc_spec("owner/").unwrap_err(), + DocSpecError::EmptyRepo("owner/".into()) + ); + } + + #[test] + fn parse_doc_spec_ecosystem_and_name() { + assert_eq!( + parse_doc_spec("npm:next@canary").unwrap(), + ParsedDocSpec::Ecosystem { + ecosystem: "npm".into(), + name: "next".into(), + version: "canary".into(), + } + ); + assert_eq!( + parse_doc_spec("next").unwrap(), + ParsedDocSpec::Name { + name: "next".into(), + version: "latest".into() + } + ); + assert_eq!( + parse_doc_spec("next@14").unwrap(), + ParsedDocSpec::Name { + name: "next".into(), + version: "14".into() + } + ); + // Scoped ecosystem spec with a slash stays in the ecosystem branch (has `:`). + assert_eq!( + parse_doc_spec("npm:@scope/pkg@1.0").unwrap(), + ParsedDocSpec::Ecosystem { + ecosystem: "npm".into(), + name: "@scope/pkg".into(), + version: "1.0".into(), + } + ); + } + + #[test] + fn detect_ecosystem_from_marker_files() { + let dir = tempfile::tempdir().unwrap(); + // Empty dir → npm default. + assert_eq!(detect_ecosystem(dir.path()), "npm"); + // Cargo.toml → crates (but package.json would win if present first). + std::fs::write(dir.path().join("Cargo.toml"), "").unwrap(); + assert_eq!(detect_ecosystem(dir.path()), "crates"); + // package.json takes priority (checked first). + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + assert_eq!(detect_ecosystem(dir.path()), "npm"); + } +} From 766ca1d4be0e5f1c3fa1249bfdff24c44a8e8722 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:42:52 +0900 Subject: [PATCH 11/44] feat(rust): port lockfile readers (bun/npm/package.json + parse-helpers) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports the network-free part of packages/cli/src/lockfiles/: parse-helpers (strip_peer_suffix, strip_inline_comment, trim_quotes, clean_value, split_pkg_spec scoped-aware, is_registry_version protocol filter), the bun.lock reader (hand-rolled scan equivalent to the TS `"@([^"@][^"]*)"` regex, no regex dep), package-lock.json (v2/v3 packages → v1 dependencies), and package.json (range fallback, exact:false, protocol strings skipped). npm_ecosystem_read probes the chain in priority order. The format-aware pnpm-lock.yaml / yarn.lock parsers (indent-aware stack + block parsers) are TODO — the chain const marks where they slot in to restore full priority parity. 11 tests. 70 tests green. --- crates/ask/src/lib.rs | 1 + crates/ask/src/lockfiles.rs | 353 ++++++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 crates/ask/src/lockfiles.rs diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 240fc3e..a1496b1 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -9,6 +9,7 @@ pub mod ask_json; pub mod cli; pub mod io; +pub mod lockfiles; pub mod markers; pub mod registry; pub mod resolved; diff --git a/crates/ask/src/lockfiles.rs b/crates/ask/src/lockfiles.rs new file mode 100644 index 0000000..5aa8cc6 --- /dev/null +++ b/crates/ask/src/lockfiles.rs @@ -0,0 +1,353 @@ +//! Lockfile readers — translate a dependency name into the version pinned by +//! the project's package manager. Rust port of `packages/cli/src/lockfiles/`. +//! +//! The npm-ecosystem facade probes lockfiles in priority order: +//! +//! `bun.lock → package-lock.json → pnpm-lock.yaml → yarn.lock → package.json` +//! +//! The first hit wins; the `package.json` fallback returns a *range* (not an +//! exact pin), so callers can decide whether to normalize it via a resolver. +//! +//! **Port status:** `bun.lock`, `package-lock.json`, and `package.json` readers +//! are ported. The format-aware `pnpm-lock.yaml` / `yarn.lock` parsers (the +//! gotcha-heavy ones) are still TODO — [`npm_ecosystem_chain`] marks exactly +//! where they slot in, so wiring them later restores full priority parity. + +use std::path::Path; + +/// A resolved dependency version plus provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LockfileHit { + /// The resolved version (exact pin if `exact`, otherwise a range). + pub version: String, + /// Provenance label, e.g. `"bun.lock"`, `"package.json"`. + pub source: String, + /// True when read from a lockfile, false when read from a manifest range. + pub exact: bool, +} + +/// Per-format reader: probes a single lockfile/manifest and returns `None` when +/// the file is missing or the package is absent. +pub struct LockfileReader { + /// Filename inspected, relative to `project_dir`. + pub file: &'static str, + /// Whether hits are exact pins. + pub exact: bool, + /// Reader fn: `(name, project_dir) -> Option`. + pub read: fn(&str, &Path) -> Option, +} + +// --------------------------------------------------------------------------- +// parse-helpers — shared text utilities (port of parse-helpers.ts) +// --------------------------------------------------------------------------- + +/// Strip a pnpm peer-dependency suffix (`18.2.0(react@17.0.0)` → `18.2.0`). +/// Cuts at the first `(` so nested suffixes collapse too. +pub fn strip_peer_suffix(v: &str) -> &str { + match v.find('(') { + Some(i) => v[..i].trim_end(), + None => v.trim_end(), + } +} + +/// Strip a YAML-style inline comment, but only when `#` is preceded by a space, +/// so `github:foo/bar#branch` fragments pass through intact. +pub fn strip_inline_comment(s: &str) -> &str { + match s.find(" #") { + Some(i) => s[..i].trim_end(), + None => s, + } +} + +/// Strip any mix of surrounding single/double quotes (`^['"]+|['"]+$`). +pub fn trim_quotes(s: &str) -> &str { + s.trim_matches(['\'', '"']) +} + +/// Normalize a raw YAML value: trim, strip inline comment, strip quotes. Does +/// NOT strip peer-dep suffixes — callers do that when appropriate. +pub fn clean_value(s: &str) -> &str { + trim_quotes(strip_inline_comment(s.trim())) +} + +/// Split a `@` spec into `(name, rest)`, handling scoped names +/// (`@scope/pkg`). Returns `None` if there is no `@` separator. +pub fn split_pkg_spec(spec: &str) -> Option<(&str, &str)> { + let at_pos = match spec.strip_prefix('@') { + // Scoped: separating `@` is the next one after the leading `@` (+1 to + // map the index in `rest` back onto `spec`). + Some(rest) => rest.find('@').map(|i| i + 1)?, + None => spec.find('@')?, + }; + Some((&spec[..at_pos], &spec[at_pos + 1..])) +} + +/// Whether `v` is a version resolvable against a public registry. Lockfiles can +/// carry protocol strings (`link:`, `file:`, `workspace:`, git/URL, and yarn +/// Berry's `0.0.0-use.local`); real npm versions never contain `:`, so a colon +/// (or the Berry sentinel, or emptiness) disqualifies. +pub fn is_registry_version(v: &str) -> bool { + !v.is_empty() && v != "0.0.0-use.local" && !v.contains(':') +} + +// --------------------------------------------------------------------------- +// Readers +// --------------------------------------------------------------------------- + +/// Parse a `bun.lock` for the version of `name`. bun.lock is a text-ish format +/// where deps appear as quoted `"@"` tokens. Hand-rolled scan +/// equivalent to the TS regex `"@([^"@][^"]*)"`: literal `"@`, a +/// first version char that is neither `@` nor `"`, then up to the next `"`. +/// Returns the first valid occurrence in file order. +fn parse_bun(content: &str, name: &str) -> Option { + let needle = format!("\"{name}@"); + let mut base = 0; + while let Some(rel) = content[base..].find(&needle) { + let start = base + rel + needle.len(); + let rest = &content[start..]; + // First version char must be neither `@` (double separator) nor `"` + // (empty version). + match rest.chars().next() { + Some(c) if c != '@' && c != '"' => { + if let Some(qpos) = rest.find('"') { + return Some(rest[..qpos].to_string()); + } + } + _ => {} + } + base = base + rel + 1; // advance past this occurrence and keep scanning + } + None +} + +fn read_file(project_dir: &Path, file: &str) -> Option { + std::fs::read_to_string(project_dir.join(file)).ok() +} + +fn bun_read(name: &str, project_dir: &Path) -> Option { + let content = read_file(project_dir, "bun.lock")?; + parse_bun(&content, name).map(|version| LockfileHit { + version, + source: "bun.lock".to_string(), + exact: true, + }) +} + +/// Parse a `package-lock.json` (npm v2/v3): prefer +/// `packages["node_modules/"].version` (lockfileVersion 2+), then +/// `dependencies..version` (v1). +fn parse_npm(content: &str, name: &str) -> Option { + let json: serde_json::Value = serde_json::from_str(content).ok()?; + let pkg_key = format!("node_modules/{name}"); + if let Some(v) = json + .get("packages") + .and_then(|p| p.get(&pkg_key)) + .and_then(|e| e.get("version")) + .and_then(|v| v.as_str()) + { + return Some(v.to_string()); + } + json.get("dependencies") + .and_then(|d| d.get(name)) + .and_then(|e| e.get("version")) + .and_then(|v| v.as_str()) + .map(str::to_string) +} + +fn npm_read(name: &str, project_dir: &Path) -> Option { + let content = read_file(project_dir, "package-lock.json")?; + parse_npm(&content, name).map(|version| LockfileHit { + version, + source: "package-lock.json".to_string(), + exact: true, + }) +} + +/// Parse `package.json` for a dependency range (dependencies → dev → peer → +/// optional). Non-registry protocol strings are skipped. The hit is NOT exact. +fn parse_package_json(content: &str, name: &str) -> Option { + let json: serde_json::Value = serde_json::from_str(content).ok()?; + let lookup = |field: &str| { + json.get(field) + .and_then(|m| m.get(name)) + .and_then(|v| v.as_str()) + .map(str::to_string) + }; + let value = lookup("dependencies") + .or_else(|| lookup("devDependencies")) + .or_else(|| lookup("peerDependencies")) + .or_else(|| lookup("optionalDependencies"))?; + is_registry_version(&value).then_some(value) +} + +fn package_json_read(name: &str, project_dir: &Path) -> Option { + let content = read_file(project_dir, "package.json")?; + parse_package_json(&content, name).map(|version| LockfileHit { + version, + source: "package.json".to_string(), + exact: false, + }) +} + +pub const BUN_LOCK_READER: LockfileReader = LockfileReader { + file: "bun.lock", + exact: true, + read: bun_read, +}; +pub const NPM_LOCK_READER: LockfileReader = LockfileReader { + file: "package-lock.json", + exact: true, + read: npm_read, +}; +pub const PACKAGE_JSON_READER: LockfileReader = LockfileReader { + file: "package.json", + exact: false, + read: package_json_read, +}; + +/// The npm-ecosystem chain in priority order. +/// +/// TODO(rust-port): insert `pnpm-lock.yaml` then `yarn.lock` readers between +/// `NPM_LOCK_READER` and `PACKAGE_JSON_READER` once their format-aware parsers +/// are ported — that restores full priority parity with lockfiles/index.ts. +pub const NPM_ECOSYSTEM_CHAIN: &[LockfileReader] = + &[BUN_LOCK_READER, NPM_LOCK_READER, PACKAGE_JSON_READER]; + +/// Probe the npm-ecosystem chain in order; return the first hit. +pub fn npm_ecosystem_read(name: &str, project_dir: &Path) -> Option { + for reader in NPM_ECOSYSTEM_CHAIN { + if let Some(hit) = (reader.read)(name, project_dir) { + return Some(hit); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- parse-helpers ---- + + #[test] + fn strip_peer_suffix_cases() { + assert_eq!(strip_peer_suffix("18.2.0(react@17.0.0)"), "18.2.0"); + assert_eq!(strip_peer_suffix("18.2.0(a@1)(b@2(c@3))"), "18.2.0"); + assert_eq!(strip_peer_suffix("1.0.0 "), "1.0.0"); + } + + #[test] + fn strip_inline_comment_keeps_url_fragments() { + assert_eq!(strip_inline_comment("1.0.0 # a comment"), "1.0.0"); + assert_eq!( + strip_inline_comment("github:foo/bar#branch"), + "github:foo/bar#branch" + ); + } + + #[test] + fn trim_and_clean() { + assert_eq!(trim_quotes("\"'x'\""), "x"); + assert_eq!(clean_value(" \"1.0.0\" # c "), "1.0.0"); + } + + #[test] + fn split_pkg_spec_scoped_and_plain() { + assert_eq!(split_pkg_spec("next@15.0.0"), Some(("next", "15.0.0"))); + assert_eq!( + split_pkg_spec("@scope/pkg@1.2.3"), + Some(("@scope/pkg", "1.2.3")) + ); + assert_eq!(split_pkg_spec("noatsign"), None); + } + + #[test] + fn is_registry_version_rejects_protocols() { + assert!(is_registry_version("1.2.3")); + assert!(!is_registry_version("workspace:*")); + assert!(!is_registry_version("link:../pkg")); + assert!(!is_registry_version("0.0.0-use.local")); + assert!(!is_registry_version("")); + } + + // ---- bun ---- + + #[test] + fn bun_finds_scoped_and_plain() { + let content = r#"{ + "packages": { + "next": ["next@15.0.3", {}], + "@scope/pkg": ["@scope/pkg@1.2.3", {}] + } + }"#; + assert_eq!(parse_bun(content, "next").as_deref(), Some("15.0.3")); + assert_eq!(parse_bun(content, "@scope/pkg").as_deref(), Some("1.2.3")); + assert_eq!(parse_bun(content, "absent"), None); + } + + #[test] + fn bun_skips_empty_or_double_at_token() { + // `"next@"` (empty) and `"next@@"` (leading @) must not match; the real + // pin later in the file wins. + let content = "\"next@\": x\n\"next@1.0.0\": y\n"; + assert_eq!(parse_bun(content, "next").as_deref(), Some("1.0.0")); + } + + // ---- npm ---- + + #[test] + fn npm_prefers_packages_then_dependencies() { + let v3 = r#"{"packages":{"node_modules/next":{"version":"15.0.3"}}}"#; + assert_eq!(parse_npm(v3, "next").as_deref(), Some("15.0.3")); + let v1 = r#"{"dependencies":{"next":{"version":"14.0.0"}}}"#; + assert_eq!(parse_npm(v1, "next").as_deref(), Some("14.0.0")); + assert_eq!(parse_npm("{}", "next"), None); + assert_eq!(parse_npm("not json", "next"), None); + } + + // ---- package.json ---- + + #[test] + fn package_json_ranges_and_protocol_skip() { + let json = r#"{ + "dependencies": { "next": "^15.0.0", "linked": "link:../x" }, + "devDependencies": { "vitest": "1.2.3" } + }"#; + assert_eq!(parse_package_json(json, "next").as_deref(), Some("^15.0.0")); + assert_eq!(parse_package_json(json, "vitest").as_deref(), Some("1.2.3")); + assert_eq!(parse_package_json(json, "linked"), None); // protocol skipped + assert_eq!(parse_package_json(json, "absent"), None); + } + + // ---- facade ---- + + #[test] + fn chain_priority_bun_beats_package_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("bun.lock"), "\"next@15.0.3\": {}").unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"next":"^14.0.0"}}"#, + ) + .unwrap(); + let hit = npm_ecosystem_read("next", dir.path()).unwrap(); + assert_eq!(hit.version, "15.0.3"); + assert_eq!(hit.source, "bun.lock"); + assert!(hit.exact); + } + + #[test] + fn chain_falls_back_to_package_json_range() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"next":"^14.0.0"}}"#, + ) + .unwrap(); + let hit = npm_ecosystem_read("next", dir.path()).unwrap(); + assert_eq!(hit.version, "^14.0.0"); + assert_eq!(hit.source, "package.json"); + assert!(!hit.exact); + assert!(npm_ecosystem_read("absent", dir.path()).is_none()); + } +} From 1e27111d2e06fd724e83762ae767b73649133c9a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:45:10 +0900 Subject: [PATCH 12/44] feat(rust): port yarn.lock parser into lockfiles chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports parseYarnLock (yarn.ts): the block-based parser handling classic v1 and Berry v2+ in one path — blank-line block splitting, comment/header/body separation, `, `-split multi-specifier headers, the `version` vs `versions:` key disambiguation, peer-suffix stripping, and the workspace-sentinel/protocol skip that lets a later block supply a real version. Wired into NPM_ECOSYSTEM_CHAIN in priority position (bun → package-lock → yarn → package.json); only pnpm-lock.yaml remains TODO. 3 tests. 73 tests green. --- crates/ask/src/lockfiles.rs | 162 ++++++++++++++++++++++++++++++++++-- 1 file changed, 153 insertions(+), 9 deletions(-) diff --git a/crates/ask/src/lockfiles.rs b/crates/ask/src/lockfiles.rs index 5aa8cc6..2e72614 100644 --- a/crates/ask/src/lockfiles.rs +++ b/crates/ask/src/lockfiles.rs @@ -8,10 +8,10 @@ //! The first hit wins; the `package.json` fallback returns a *range* (not an //! exact pin), so callers can decide whether to normalize it via a resolver. //! -//! **Port status:** `bun.lock`, `package-lock.json`, and `package.json` readers -//! are ported. The format-aware `pnpm-lock.yaml` / `yarn.lock` parsers (the -//! gotcha-heavy ones) are still TODO — [`npm_ecosystem_chain`] marks exactly -//! where they slot in, so wiring them later restores full priority parity. +//! **Port status:** `bun.lock`, `package-lock.json`, `yarn.lock`, and +//! `package.json` readers are ported. The `pnpm-lock.yaml` indent-aware stack +//! parser is still TODO — [`NPM_ECOSYSTEM_CHAIN`] marks exactly where it slots +//! in, so wiring it later restores full priority parity. use std::path::Path; @@ -189,6 +189,94 @@ fn package_json_read(name: &str, project_dir: &Path) -> Option { }) } +/// Block-based `yarn.lock` parser handling both classic v1 and Berry v2+ in one +/// path (port of `parseYarnLock`, itself from opensrc's `core/version.rs`). +/// +/// Blocks are separated by blank lines. For each block whose header mentions +/// `pkg` in ANY comma-separated specifier, the nearest body `version` line wins +/// — unless it is a workspace sentinel / protocol string, in which case a later +/// block may still provide a real version. +pub fn parse_yarn_lock(text: &str, pkg: &str) -> Option { + // Split into blocks on blank lines (CR-stripped). + let mut blocks: Vec> = Vec::new(); + let mut current: Vec<&str> = Vec::new(); + for raw in text.split('\n') { + let line = raw.strip_suffix('\r').unwrap_or(raw); + if line.trim().is_empty() { + if !current.is_empty() { + blocks.push(std::mem::take(&mut current)); + } + } else { + current.push(line); + } + } + if !current.is_empty() { + blocks.push(current); + } + + for block in blocks { + let mut header: Option<&str> = None; + let mut body: Vec<&str> = Vec::new(); + + for line in block { + if line.trim_start().starts_with('#') { + continue; + } + let first_is_ws = line.chars().next().is_some_and(char::is_whitespace); + if header.is_none() && !first_is_ws { + header = Some(line); + } else { + body.push(line); + } + } + + let Some(header) = header else { continue }; + if header.starts_with("__metadata:") || !header.ends_with(':') { + continue; + } + let header_body = &header[..header.len() - 1]; + + // Split on `, ` to cover v1 multi-specifier headers and Berry's + // `foo@npm:^1, foo@workspace:*`; trim_quotes handles the outer quotes. + let matched = header_body.split(", ").any(|s| { + let spec = trim_quotes(s.trim()); + split_pkg_spec(spec).is_some_and(|(name, _)| name == pkg) + }); + if !matched { + continue; + } + + for line in body { + let trimmed = line.trim_start(); + let Some(rest) = trimmed.strip_prefix("version") else { + continue; + }; + // Must be followed by `:` (Berry) or whitespace (v1) — not `versions:`. + match rest.chars().next() { + Some(':') | Some(' ') | Some('\t') => {} + _ => continue, + } + let rest = rest.trim_start(); + let rest = rest.strip_prefix(':').unwrap_or(rest); + let stripped = strip_peer_suffix(clean_value(rest)); + if is_registry_version(stripped) { + return Some(stripped.to_string()); + } + } + } + + None +} + +fn yarn_read(name: &str, project_dir: &Path) -> Option { + let content = read_file(project_dir, "yarn.lock")?; + parse_yarn_lock(&content, name).map(|version| LockfileHit { + version, + source: "yarn.lock".to_string(), + exact: true, + }) +} + pub const BUN_LOCK_READER: LockfileReader = LockfileReader { file: "bun.lock", exact: true, @@ -204,14 +292,24 @@ pub const PACKAGE_JSON_READER: LockfileReader = LockfileReader { exact: false, read: package_json_read, }; +pub const YARN_LOCK_READER: LockfileReader = LockfileReader { + file: "yarn.lock", + exact: true, + read: yarn_read, +}; /// The npm-ecosystem chain in priority order. /// -/// TODO(rust-port): insert `pnpm-lock.yaml` then `yarn.lock` readers between -/// `NPM_LOCK_READER` and `PACKAGE_JSON_READER` once their format-aware parsers -/// are ported — that restores full priority parity with lockfiles/index.ts. -pub const NPM_ECOSYSTEM_CHAIN: &[LockfileReader] = - &[BUN_LOCK_READER, NPM_LOCK_READER, PACKAGE_JSON_READER]; +/// TODO(rust-port): insert the `pnpm-lock.yaml` reader between `NPM_LOCK_READER` +/// and `YARN_LOCK_READER` once its indent-aware stack parser is ported — that +/// restores full priority parity with lockfiles/index.ts +/// (bun → package-lock → pnpm → yarn → package.json). +pub const NPM_ECOSYSTEM_CHAIN: &[LockfileReader] = &[ + BUN_LOCK_READER, + NPM_LOCK_READER, + YARN_LOCK_READER, + PACKAGE_JSON_READER, +]; /// Probe the npm-ecosystem chain in order; return the first hit. pub fn npm_ecosystem_read(name: &str, project_dir: &Path) -> Option { @@ -319,6 +417,52 @@ mod tests { assert_eq!(parse_package_json(json, "absent"), None); } + // ---- yarn ---- + + #[test] + fn yarn_v1_multi_specifier_header() { + let text = "\ +# yarn lockfile v1 + + +\"next@^15.0.0\", \"next@~15.0.3\": + version \"15.0.3\" + resolved \"https://...\" +"; + assert_eq!(parse_yarn_lock(text, "next").as_deref(), Some("15.0.3")); + } + + #[test] + fn yarn_berry_and_peer_suffix_and_protocol_skip() { + // Berry header form + a peer suffix on the version; workspace sentinel + // block is skipped in favor of the real one. + let text = "\ +__metadata: + version: 8 + +\"root@workspace:.\": + version: 0.0.0-use.local + +\"react@npm:^18.0.0, react@npm:^18.2.0\": + version: 18.2.0(loose) +"; + assert_eq!(parse_yarn_lock(text, "react").as_deref(), Some("18.2.0")); + // The workspace root resolves to a sentinel → no registry version. + assert_eq!(parse_yarn_lock(text, "root"), None); + } + + #[test] + fn yarn_versions_key_is_not_the_version_key() { + // `versions:` (plural) must not be mistaken for the `version` key. + let text = "\ +\"pkg@^1.0.0\": + versions: + - 1.0.0 + version \"1.0.0\" +"; + assert_eq!(parse_yarn_lock(text, "pkg").as_deref(), Some("1.0.0")); + } + // ---- facade ---- #[test] From 35eeaebd9674e0d6e0dc02be6bae182b86c3e830 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:49:15 +0900 Subject: [PATCH 13/44] =?UTF-8?q?feat(rust):=20port=20pnpm-lock.yaml=20par?= =?UTF-8?q?ser=20=E2=80=94=20lockfile=20chain=20now=20at=20full=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports lockfiles/pnpm.ts: the indent-aware stack machine (v5–v9) with a Frame enum, dependency-graph construction from snapshots:/packages:, and BFS transitive resolution from importer roots. Preserves the gotcha-critical behaviors: peer-suffix stripping so `18.2.0(react@17)` never leaks the inner package, per-importer registry filtering so a workspace/link entry can't block a real version, v5 `/name/version` slash keys with `_peerhash` stripping, and the 4-tier priority (importer → top-level → transitive BFS → packages fallback). lockfiles.rs is now a module dir (lockfiles/{mod,pnpm}.rs) to stay under the 500-LOC limit. PNPM_LOCK_READER slots into NPM_ECOSYSTEM_CHAIN in priority position, so the chain (bun → package-lock → pnpm → yarn → package.json) now has full parity with lockfiles/index.ts. 7 tests. 79 tests green. --- .../src/{lockfiles.rs => lockfiles/mod.rs} | 20 +- crates/ask/src/lockfiles/pnpm.rs | 519 ++++++++++++++++++ 2 files changed, 529 insertions(+), 10 deletions(-) rename crates/ask/src/{lockfiles.rs => lockfiles/mod.rs} (96%) create mode 100644 crates/ask/src/lockfiles/pnpm.rs diff --git a/crates/ask/src/lockfiles.rs b/crates/ask/src/lockfiles/mod.rs similarity index 96% rename from crates/ask/src/lockfiles.rs rename to crates/ask/src/lockfiles/mod.rs index 2e72614..696a3c1 100644 --- a/crates/ask/src/lockfiles.rs +++ b/crates/ask/src/lockfiles/mod.rs @@ -8,10 +8,13 @@ //! The first hit wins; the `package.json` fallback returns a *range* (not an //! exact pin), so callers can decide whether to normalize it via a resolver. //! -//! **Port status:** `bun.lock`, `package-lock.json`, `yarn.lock`, and -//! `package.json` readers are ported. The `pnpm-lock.yaml` indent-aware stack -//! parser is still TODO — [`NPM_ECOSYSTEM_CHAIN`] marks exactly where it slots -//! in, so wiring it later restores full priority parity. +//! **Port status:** all five readers (`bun.lock`, `package-lock.json`, +//! `pnpm-lock.yaml`, `yarn.lock`, `package.json`) are ported, restoring full +//! priority parity with `lockfiles/index.ts`. + +mod pnpm; + +pub use pnpm::{parse_pnpm_lock, PNPM_LOCK_READER}; use std::path::Path; @@ -298,15 +301,12 @@ pub const YARN_LOCK_READER: LockfileReader = LockfileReader { read: yarn_read, }; -/// The npm-ecosystem chain in priority order. -/// -/// TODO(rust-port): insert the `pnpm-lock.yaml` reader between `NPM_LOCK_READER` -/// and `YARN_LOCK_READER` once its indent-aware stack parser is ported — that -/// restores full priority parity with lockfiles/index.ts -/// (bun → package-lock → pnpm → yarn → package.json). +/// The npm-ecosystem chain in priority order: +/// bun → package-lock → pnpm → yarn → package.json. pub const NPM_ECOSYSTEM_CHAIN: &[LockfileReader] = &[ BUN_LOCK_READER, NPM_LOCK_READER, + PNPM_LOCK_READER, YARN_LOCK_READER, PACKAGE_JSON_READER, ]; diff --git a/crates/ask/src/lockfiles/pnpm.rs b/crates/ask/src/lockfiles/pnpm.rs new file mode 100644 index 0000000..149333c --- /dev/null +++ b/crates/ask/src/lockfiles/pnpm.rs @@ -0,0 +1,519 @@ +//! Format-aware `pnpm-lock.yaml` parser — Rust port of `lockfiles/pnpm.ts` +//! (itself from opensrc's `core/version.rs`). +//! +//! An indent-aware stack machine covering pnpm v5–v9: importers, +//! dev/optionalDependencies, and nested peer-dep suffixes. While parsing it +//! builds a dependency graph from `snapshots:` (v9) or `packages:` (v6–v8); when +//! a direct lookup misses, a BFS from root-importer deps picks the +//! root-reachable version instead of a lexicographic first match. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use super::{ + clean_value, is_registry_version, read_file, split_pkg_spec, strip_peer_suffix, trim_quotes, + LockfileHit, LockfileReader, +}; + +/// A dependency-graph node. Map keys are the full snapshot id +/// (`@[]`). +struct PnpmNode { + name: String, + /// Version with peer suffix stripped — what we'd return to the caller. + version: String, + /// Snapshot ids of this node's direct dependencies. + deps: Vec, +} + +/// The snapshot dependency graph. +struct PnpmGraph { + nodes: HashMap, + /// Snapshot ids that are direct deps of any importer (or top-level + /// `dependencies:` in v5/v6 non-workspace lockfiles). Insertion order is + /// significant for the BFS. + roots: Vec, +} + +/// Where a direct dependency entry was found. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Origin { + Root, + Importer, +} + +/// A frame on the indent-aware parse stack. `base` is the indent of the line +/// that opened the frame; children sit at strictly greater indent. +#[derive(Clone)] +enum Frame { + Importers { + base: usize, + }, + Importer { + base: usize, + }, + DepGroup { + base: usize, + origin: Origin, + }, + /// Block-form dep entry awaiting a nested `version:` line. + DepBlock { + base: usize, + origin: Origin, + pkg_name: String, + }, + Packages { + base: usize, + }, + Snapshots { + base: usize, + }, + /// Inside a `packages:`/`snapshots:` entry, collecting its subkeys. + PkgEntry { + base: usize, + key: String, + }, + /// Inside a pkg entry's `dependencies:`/`optionalDependencies:` block. + PkgDeps { + base: usize, + owner: String, + }, +} + +impl Frame { + fn base(&self) -> usize { + match self { + Frame::Importers { base } + | Frame::Importer { base } + | Frame::DepGroup { base, .. } + | Frame::DepBlock { base, .. } + | Frame::Packages { base } + | Frame::Snapshots { base } + | Frame::PkgEntry { base, .. } + | Frame::PkgDeps { base, .. } => *base, + } + } +} + +/// Mutable state threaded through the per-frame line handlers. +struct ParseState { + pkg: String, + stack: Vec, + graph: PnpmGraph, + importer_match: Option, + top_match: Option, + packages_fallback: Option, +} + +fn is_dep_group_key(key: &str) -> bool { + matches!( + key, + "dependencies" | "devDependencies" | "optionalDependencies" + ) +} + +/// Split a `packages:`/`snapshots:` entry key (leading `/` already stripped) +/// into name + version-with-peer + the node key used in the dep graph. +/// +/// v6–v9 keys are `@[]` (node key = raw key). v5 +/// keys are `/[_peerhash]` (slash separator): split at the LAST +/// slash so scoped names survive, strip `_peerhash`, and canonicalize the node +/// key to `@` (v5 edges reference deps as `: `). +fn split_packages_key(key: &str) -> Option<(String, String, String)> { + if let Some((name, version_with_peer)) = split_pkg_spec(key) { + return Some(( + name.to_string(), + version_with_peer.to_string(), + key.to_string(), + )); + } + let i = key.rfind('/')?; + if i == 0 || i == key.len() - 1 { + return None; + } + let name = &key[..i]; + let underscore = key[i..].find('_').map(|x| x + i); + let version = match underscore { + Some(u) => &key[i + 1..u], + None => &key[i + 1..], + }; + if version.is_empty() { + return None; + } + Some(( + name.to_string(), + version.to_string(), + format!("{name}@{version}"), + )) +} + +/// Pop frames whose scope ended at this indent; return the frame the line +/// belongs to (`None` = document/root level). Returns a clone so callers can +/// mutate the stack/graph without a borrow conflict. +fn current_frame(stack: &mut Vec, indent: usize) -> Option { + while let Some(top) = stack.last() { + if indent <= top.base() { + stack.pop(); + } else { + break; + } + } + stack.last().cloned() +} + +/// Record a direct dependency entry: seed the graph roots and capture the +/// version when the entry names the package we're looking for. +fn capture_direct(state: &mut ParseState, dep_name: &str, raw_value: &str, origin: Origin) { + let cleaned = clean_value(raw_value); + let stripped = strip_peer_suffix(cleaned); + // Root key uses the raw (peer-including) value so it matches `snapshots:`. + state.graph.roots.push(format!("{dep_name}@{cleaned}")); + + // Filter at capture so workspace/link/file versions in one importer don't + // block a real version in a later importer. + if dep_name != state.pkg || !is_registry_version(stripped) { + return; + } + if origin == Origin::Importer && state.importer_match.is_none() { + state.importer_match = Some(stripped.to_string()); + } else if origin == Origin::Root && state.top_match.is_none() { + state.top_match = Some(stripped.to_string()); + } +} + +fn handle_root_line(state: &mut ParseState, indent: usize, content: &str) { + if indent != 0 || !content.ends_with(':') { + return; + } + let key = content[..content.len() - 1].trim(); + if key == "importers" { + state.stack.push(Frame::Importers { base: indent }); + } else if is_dep_group_key(key) { + state.stack.push(Frame::DepGroup { + base: indent, + origin: Origin::Root, + }); + } else if key == "packages" { + state.stack.push(Frame::Packages { base: indent }); + } else if key == "snapshots" { + state.stack.push(Frame::Snapshots { base: indent }); + } +} + +fn handle_importers_line(state: &mut ParseState, indent: usize, content: &str) { + if content.ends_with(':') { + state.stack.push(Frame::Importer { base: indent }); + } +} + +fn handle_importer_line(state: &mut ParseState, indent: usize, content: &str) { + if content.ends_with(':') && is_dep_group_key(content[..content.len() - 1].trim()) { + state.stack.push(Frame::DepGroup { + base: indent, + origin: Origin::Importer, + }); + } +} + +fn handle_dep_group_line(state: &mut ParseState, origin: Origin, indent: usize, content: &str) { + let Some(sep) = content.find(':') else { return }; + let dep_name = trim_quotes(content[..sep].trim()).to_string(); + let raw_value = content[sep + 1..].trim(); + if raw_value.is_empty() { + state.stack.push(Frame::DepBlock { + base: indent, + origin, + pkg_name: dep_name, + }); + } else { + capture_direct(state, &dep_name, raw_value, origin); + } +} + +fn handle_dep_block_line(state: &mut ParseState, origin: Origin, pkg_name: &str, content: &str) { + let Some(rest) = content.strip_prefix("version:") else { + return; + }; + capture_direct(state, pkg_name, rest, origin); + state.stack.pop(); +} + +fn handle_packages_line(state: &mut ParseState, indent: usize, content: &str) { + let Some(sep) = content.find(':') else { return }; + let raw_key = trim_quotes(content[..sep].trim()); + let key = raw_key.strip_prefix('/').unwrap_or(raw_key); + let value_part = &content[sep + 1..]; + + let Some((name, version_with_peer, node_key)) = split_packages_key(key) else { + return; + }; + let version = strip_peer_suffix(&version_with_peer).to_string(); + + state + .graph + .nodes + .entry(node_key.clone()) + .or_insert_with(|| PnpmNode { + name: name.clone(), + version: version.clone(), + deps: Vec::new(), + }); + + if name == state.pkg && state.packages_fallback.is_none() && is_registry_version(&version) { + state.packages_fallback = Some(version); + } + + if value_part.trim().is_empty() { + state.stack.push(Frame::PkgEntry { + base: indent, + key: node_key, + }); + } + // Else: inline value like `{}` — no children to parse. +} + +fn handle_pkg_entry_line(state: &mut ParseState, key: &str, indent: usize, content: &str) { + if !content.ends_with(':') { + return; + } + let sub_key = content[..content.len() - 1].trim(); + if sub_key == "dependencies" || sub_key == "optionalDependencies" { + state.stack.push(Frame::PkgDeps { + base: indent, + owner: key.to_string(), + }); + } + // Ignore resolution/engines/peerDependencies/etc. +} + +fn handle_pkg_deps_line(state: &mut ParseState, owner: &str, content: &str) { + let Some(sep) = content.find(':') else { return }; + let dep_name = trim_quotes(content[..sep].trim()).to_string(); + let dep_value = clean_value(&content[sep + 1..]); + if !dep_value.is_empty() { + if let Some(node) = state.graph.nodes.get_mut(owner) { + node.deps.push(format!("{dep_name}@{dep_value}")); + } + } +} + +fn dispatch_line(state: &mut ParseState, indent: usize, content: &str) { + let Some(top) = current_frame(&mut state.stack, indent) else { + handle_root_line(state, indent, content); + return; + }; + match top { + Frame::Importers { .. } => handle_importers_line(state, indent, content), + Frame::Importer { .. } => handle_importer_line(state, indent, content), + Frame::DepGroup { origin, .. } => handle_dep_group_line(state, origin, indent, content), + Frame::DepBlock { + origin, pkg_name, .. + } => handle_dep_block_line(state, origin, &pkg_name, content), + Frame::Packages { .. } | Frame::Snapshots { .. } => { + handle_packages_line(state, indent, content) + } + Frame::PkgEntry { key, .. } => handle_pkg_entry_line(state, &key, indent, content), + Frame::PkgDeps { owner, .. } => handle_pkg_deps_line(state, &owner, content), + } +} + +/// Parse a `pnpm-lock.yaml` and return the installed version of `pkg`. +/// +/// Priority: (1) direct match in an importer dep group, (2) direct match in +/// top-level dep groups (v5/v6), (3) transitive BFS through the snapshot graph, +/// (4) first matching `packages:`/`snapshots:` key. +pub fn parse_pnpm_lock(text: &str, pkg: &str) -> Option { + let mut state = ParseState { + pkg: pkg.to_string(), + stack: Vec::new(), + graph: PnpmGraph { + nodes: HashMap::new(), + roots: Vec::new(), + }, + importer_match: None, + top_match: None, + packages_fallback: None, + }; + + for raw in text.split('\n') { + let line = raw.strip_suffix('\r').unwrap_or(raw); + let trimmed_start = line.trim_start(); + if trimmed_start.is_empty() || trimmed_start.starts_with('#') { + continue; + } + let indent = line.len() - trimmed_start.len(); + dispatch_line(&mut state, indent, trimmed_start); + } + + state + .importer_match + .or(state.top_match) + .or_else(|| resolve_transitive(&state.graph, pkg)) + .or(state.packages_fallback) +} + +/// BFS from `graph.roots` through the snapshot dep graph, returning the version +/// of the first reached node whose name matches `pkg`. BFS (not DFS) picks the +/// version at the shallowest transitive depth — closest to what's hoisted. +fn resolve_transitive(graph: &PnpmGraph, pkg: &str) -> Option { + if graph.nodes.is_empty() || graph.roots.is_empty() { + return None; + } + let mut visited: HashSet<&str> = HashSet::new(); + // Index-pointer BFS: the queue only grows, iterated in insertion order. + let mut queue: Vec<&str> = graph.roots.iter().map(String::as_str).collect(); + let mut i = 0; + while i < queue.len() { + let key = queue[i]; + i += 1; + if !visited.insert(key) { + continue; + } + let Some(node) = graph.nodes.get(key) else { + continue; + }; + if node.name == pkg && is_registry_version(&node.version) { + return Some(node.version.clone()); + } + for dep in &node.deps { + if !visited.contains(dep.as_str()) { + queue.push(dep.as_str()); + } + } + } + None +} + +fn pnpm_read(name: &str, project_dir: &Path) -> Option { + let content = read_file(project_dir, "pnpm-lock.yaml")?; + parse_pnpm_lock(&content, name).map(|version| LockfileHit { + version, + source: "pnpm-lock.yaml".to_string(), + exact: true, + }) +} + +pub const PNPM_LOCK_READER: LockfileReader = LockfileReader { + file: "pnpm-lock.yaml", + exact: true, + read: pnpm_read, +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v9_importer_direct_match() { + let text = "\ +lockfileVersion: '9.0' + +importers: + .: + dependencies: + next: + specifier: ^15.0.0 + version: 15.0.3 +"; + assert_eq!(parse_pnpm_lock(text, "next").as_deref(), Some("15.0.3")); + } + + #[test] + fn peer_suffix_does_not_leak_inner_package() { + // `react-dom@18.2.0(react@18.2.0)`: looking up `react` must NOT return + // the version embedded in react-dom's peer suffix. + let text = "\ +importers: + .: + dependencies: + react: + specifier: ^18.0.0 + version: 18.2.0 + react-dom: + specifier: ^18.0.0 + version: 18.2.0(react@18.2.0) +"; + assert_eq!(parse_pnpm_lock(text, "react").as_deref(), Some("18.2.0")); + assert_eq!( + parse_pnpm_lock(text, "react-dom").as_deref(), + Some("18.2.0") + ); + } + + #[test] + fn importer_beats_workspace_protocol() { + // A workspace/link importer entry must not block a real version. + let text = "\ +importers: + .: + dependencies: + pkg: + specifier: workspace:* + version: link:../pkg + packages/app: + dependencies: + pkg: + specifier: ^1.0.0 + version: 1.2.3 +"; + assert_eq!(parse_pnpm_lock(text, "pkg").as_deref(), Some("1.2.3")); + } + + #[test] + fn transitive_bfs_from_snapshots() { + // `left-pad` is not a direct dep; it is reachable transitively via the + // snapshot graph and resolved by BFS. + let text = "\ +importers: + .: + dependencies: + a: + specifier: ^1.0.0 + version: 1.0.0 + +snapshots: + a@1.0.0: + dependencies: + left-pad: 1.3.0 + left-pad@1.3.0: {} +"; + assert_eq!(parse_pnpm_lock(text, "left-pad").as_deref(), Some("1.3.0")); + } + + #[test] + fn v5_slash_keys_and_packages_fallback() { + // v5-style `/name/version[_peerhash]` keys, resolved via the packages + // fallback. The slash-split branch is only reached for keys with NO `@` + // (a `@` anywhere makes split_pkg_spec win and take the + // `@` interpretation — identical to the TS). + let text = "\ +packages: + /foo/1.0.0: + resolution: {integrity: sha512-xxx} + /@scope/pkg/2.3.4: + resolution: {integrity: sha512-yyy} + /bar/3.0.0_abc123hash: + resolution: {integrity: sha512-zzz} +"; + assert_eq!(parse_pnpm_lock(text, "foo").as_deref(), Some("1.0.0")); + // Scoped v5 key (no peer hash) → split at the last slash. + assert_eq!( + parse_pnpm_lock(text, "@scope/pkg").as_deref(), + Some("2.3.4") + ); + // v5 peer hash (no `@`) is stripped at the `_`. + assert_eq!(parse_pnpm_lock(text, "bar").as_deref(), Some("3.0.0")); + } + + #[test] + fn missing_package_is_none() { + let text = "\ +importers: + .: + dependencies: + next: + specifier: ^15.0.0 + version: 15.0.3 +"; + assert_eq!(parse_pnpm_lock(text, "absent"), None); + } +} From 8fc5c6de75770cc827a315af1e4d8be1c1248d46 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 02:51:21 +0900 Subject: [PATCH 14/44] feat(rust): port parse_repo_url (resolvers/utils) with regex Phase 3 of the Rust port. Ports resolvers/utils.ts parseRepoUrl: normalizes git+https/https/git/ssh/bare github.com URLs (and ones with extra path segments) to owner/repo, stripping a trailing .git; returns None for non-GitHub or empty input. Uses the RE_GITHUB_URL regex verbatim via a LazyLock (adds the regex dep, which upcoming agents/discovery modules also need). The per-ecosystem HTTP resolvers (npm/pypi/pub/maven) are deferred to the ureq phase. 3 tests. 82 tests green. --- Cargo.lock | 39 +++++++++++++++++++ crates/ask/Cargo.toml | 1 + crates/ask/src/lib.rs | 1 + crates/ask/src/resolvers.rs | 74 +++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 crates/ask/src/resolvers.rs diff --git a/Cargo.lock b/Cargo.lock index e268b98..a1b0424 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "1.0.0" @@ -64,6 +73,7 @@ version = "0.4.8" dependencies = [ "anyhow", "clap", + "regex", "serde", "serde_json", "sha2", @@ -295,6 +305,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustix" version = "1.1.4" diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml index d6b54e8..0c59b88 100644 --- a/crates/ask/Cargo.toml +++ b/crates/ask/Cargo.toml @@ -31,6 +31,7 @@ anyhow = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +regex = { workspace = true } sha2 = { workspace = true } time = { workspace = true } diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index a1496b1..40a70d0 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -13,6 +13,7 @@ pub mod lockfiles; pub mod markers; pub mod registry; pub mod resolved; +pub mod resolvers; pub mod spec; use std::fmt; diff --git a/crates/ask/src/resolvers.rs b/crates/ask/src/resolvers.rs new file mode 100644 index 0000000..1a9a873 --- /dev/null +++ b/crates/ask/src/resolvers.rs @@ -0,0 +1,74 @@ +//! Ecosystem resolvers — turn package metadata into a GitHub `owner/repo`. +//! Rust port of `packages/cli/src/resolvers/`. +//! +//! **Port status:** the shared [`parse_repo_url`] helper (resolvers/utils.ts) is +//! ported. The per-ecosystem resolvers (npm/pypi/pub/maven) fetch registry +//! metadata over HTTP and are deferred to the phase that introduces the HTTP +//! client (ureq), alongside the registry HTTP surface. + +use std::sync::LazyLock; + +use regex::Regex; + +// `github.com` followed by `/` or `:`, then owner (no `/`), `/`, then repo up to +// the first `/`, `#`, `?`, or whitespace. Mirrors the TS `RE_GITHUB_URL`. +static RE_GITHUB_URL: LazyLock = + LazyLock::new(|| Regex::new(r"github\.com[/:]([^/]+)/([^/#?\s]+)").unwrap()); + +/// Parse a repository URL into `owner/repo` form (port of `parseRepoUrl`). +/// +/// Handles `git+https://…`, `https://…`, `git://…`, `ssh://git@…`, +/// `github.com/owner/repo`, and URLs with extra path segments. A trailing +/// `.git` on the repo is stripped. Returns `None` for non-GitHub or empty input. +pub fn parse_repo_url(url: Option<&str>) -> Option { + let url = url?; + if url.is_empty() { + return None; + } + let caps = RE_GITHUB_URL.captures(url)?; + let owner = caps.get(1)?.as_str(); + let repo = caps.get(2)?.as_str(); + let repo = repo.strip_suffix(".git").unwrap_or(repo); + Some(format!("{owner}/{repo}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_common_github_url_forms() { + let cases = [ + "git+https://github.com/owner/repo.git", + "https://github.com/owner/repo", + "git://github.com/owner/repo.git", + "ssh://git@github.com/owner/repo.git", + "github.com/owner/repo", + "https://github.com/owner/repo/tree/main", + ]; + for url in cases { + assert_eq!( + parse_repo_url(Some(url)).as_deref(), + Some("owner/repo"), + "url: {url}" + ); + } + } + + #[test] + fn strips_only_trailing_dot_git() { + // `.git` mid-repo (unusual) is not stripped; only the trailing suffix. + assert_eq!( + parse_repo_url(Some("https://github.com/owner/repo.github.io")).as_deref(), + Some("owner/repo.github.io") + ); + } + + #[test] + fn rejects_non_github_and_empty() { + assert_eq!(parse_repo_url(Some("https://gitlab.com/owner/repo")), None); + assert_eq!(parse_repo_url(Some("")), None); + assert_eq!(parse_repo_url(None), None); + assert_eq!(parse_repo_url(Some("not a url")), None); + } +} From 6122b5c13551f422d35db0fd986d967dfe8c7c45 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:25:45 +0900 Subject: [PATCH 15/44] feat(rust): add injectable HTTP client abstraction (ureq + mock) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port — the network foundation for the registry lookup, ecosystem resolvers, and web/llms-txt sources. Adds an http module: - HttpClient trait (get → HttpResponse{status, body}); transport failures are Err, any HTTP status is Ok so callers branch on 404 vs other like the TS response.ok/response.status. - UreqClient: ureq 3.3 agent with a 10s global timeout (parity with the TS AbortSignal.timeout(10_000)) and http_status_as_error(false). - encode_uri_component: exact encodeURIComponent semantics (unreserved A-Za-z0-9-_.!~*'(), per-byte UTF-8 %XX) for registry catch-all path segments and Maven Solr params. - mock::MockClient (cfg(test)): canned URL→response map so every network path ports as a deterministic unit test instead of needing a live server. 3 tests. 85 tests green. --- Cargo.lock | 665 ++++++++++++++++++++++++++++++++++++++++- crates/ask/Cargo.toml | 2 + crates/ask/src/http.rs | 173 +++++++++++ crates/ask/src/lib.rs | 1 + 4 files changed, 835 insertions(+), 6 deletions(-) create mode 100644 crates/ask/src/http.rs diff --git a/Cargo.lock b/Cargo.lock index a1b0424..22d6347 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -74,14 +80,22 @@ dependencies = [ "anyhow", "clap", "regex", + "semver", "serde", "serde_json", "sha2", "tempfile", "thiserror", "time", + "ureq", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.0" @@ -97,6 +111,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -149,6 +179,35 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -158,6 +217,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -184,6 +252,32 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[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" @@ -191,7 +285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -200,6 +294,31 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -210,6 +329,17 @@ dependencies = [ "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" @@ -221,12 +351,147 @@ dependencies = [ "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 = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[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" @@ -251,12 +516,40 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -275,6 +568,21 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -334,6 +642,20 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -344,9 +666,50 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -401,12 +764,42 @@ dependencies = [ "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.118" @@ -418,6 +811,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -425,10 +829,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -481,6 +885,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "typenum" version = "1.20.1" @@ -493,6 +907,68 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -505,12 +981,36 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -520,6 +1020,159 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml index 0c59b88..22b82cb 100644 --- a/crates/ask/Cargo.toml +++ b/crates/ask/Cargo.toml @@ -32,8 +32,10 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } regex = { workspace = true } +semver = { workspace = true } sha2 = { workspace = true } time = { workspace = true } +ureq = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/ask/src/http.rs b/crates/ask/src/http.rs new file mode 100644 index 0000000..abe1ebe --- /dev/null +++ b/crates/ask/src/http.rs @@ -0,0 +1,173 @@ +//! A small injectable HTTP client abstraction shared by the registry lookup, +//! the ecosystem resolvers, and (later) the web/llms-txt sources. +//! +//! The TypeScript code calls the global `fetch`; porting that verbatim would +//! make every network path untestable without a live server. Instead callers +//! take a `&dyn HttpClient`, so production wires [`UreqClient`] (a 10s-timeout +//! ureq agent) while tests inject [`mock::MockClient`] with canned responses — +//! the same parity-by-unit-test approach used for the pure modules. + +use std::time::Duration; + +/// A completed HTTP response: the numeric status and the body as text. Non-2xx +/// responses are returned as `Ok` (not errors) so callers can branch on 404 vs +/// other statuses exactly like the TS `response.ok` / `response.status` checks. +#[derive(Debug, Clone)] +pub struct HttpResponse { + pub status: u16, + pub body: String, +} + +impl HttpResponse { + /// `true` for a 2xx status (parity with `fetch`'s `response.ok`). + pub fn ok(&self) -> bool { + (200..300).contains(&self.status) + } +} + +/// A GET-only HTTP client. `get` performs the request and reads the full body; +/// a transport failure (DNS, connect, timeout, read) is an `Err`, while any +/// HTTP status (including 4xx/5xx) is an `Ok(HttpResponse)`. +pub trait HttpClient { + fn get(&self, url: &str) -> anyhow::Result; +} + +/// The production client: a ureq agent with a 10s global timeout (parity with +/// the TS `AbortSignal.timeout(10_000)`) that surfaces HTTP status codes rather +/// than turning 4xx/5xx into transport errors. +pub struct UreqClient { + agent: ureq::Agent, +} + +impl UreqClient { + pub fn new() -> Self { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(10))) + .http_status_as_error(false) + .build() + .into(); + Self { agent } + } +} + +impl Default for UreqClient { + fn default() -> Self { + Self::new() + } +} + +impl HttpClient for UreqClient { + fn get(&self, url: &str) -> anyhow::Result { + let mut response = self.agent.get(url).call()?; + let status = response.status().as_u16(); + let body = response.body_mut().read_to_string()?; + Ok(HttpResponse { status, body }) + } +} + +/// Percent-encode a string the way JavaScript's `encodeURIComponent` does: +/// everything except the unreserved set `A-Za-z0-9-_.!~*'()` is UTF-8 encoded to +/// `%XX` (uppercase hex). Used for registry catch-all path segments (scoped npm +/// names contain `/` and `@`) and Maven Solr query params. +pub fn encode_uri_component(input: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut out = String::with_capacity(input.len()); + for &b in input.as_bytes() { + let unreserved = b.is_ascii_alphanumeric() + || matches!( + b, + b'-' | b'_' | b'.' | b'!' | b'~' | b'*' | b'\'' | b'(' | b')' + ); + if unreserved { + out.push(b as char); + } else { + out.push('%'); + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + } + out +} + +/// In-memory HTTP client for tests. Maps exact URLs to canned responses; an +/// unmapped URL returns a transport error (simulating a network failure). +#[cfg(test)] +pub mod mock { + use std::collections::HashMap; + + use super::{HttpClient, HttpResponse}; + + #[derive(Default)] + pub struct MockClient { + responses: HashMap, + } + + impl MockClient { + pub fn new() -> Self { + Self::default() + } + + /// Register a canned response for an exact URL. + pub fn with(mut self, url: &str, status: u16, body: &str) -> Self { + self.responses.insert( + url.to_string(), + HttpResponse { + status, + body: body.to_string(), + }, + ); + self + } + } + + impl HttpClient for MockClient { + fn get(&self, url: &str) -> anyhow::Result { + self.responses + .get(url) + .cloned() + .ok_or_else(|| anyhow::anyhow!("MockClient: no response registered for {url}")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_uri_component_matches_js() { + assert_eq!(encode_uri_component("next"), "next"); + assert_eq!( + encode_uri_component("@mastra/client-js"), + "%40mastra%2Fclient-js" + ); + assert_eq!(encode_uri_component("a b"), "a%20b"); + // Unreserved punctuation is left intact. + assert_eq!(encode_uri_component("-_.!~*'()"), "-_.!~*'()"); + // Multi-byte UTF-8 is percent-encoded per byte. + assert_eq!(encode_uri_component("é"), "%C3%A9"); + } + + #[test] + fn response_ok_only_for_2xx() { + let mk = |status| HttpResponse { + status, + body: String::new(), + }; + assert!(mk(200).ok()); + assert!(mk(299).ok()); + assert!(!mk(404).ok()); + assert!(!mk(500).ok()); + assert!(!mk(301).ok()); + } + + #[test] + fn mock_client_returns_canned_and_errors_on_miss() { + use super::mock::MockClient; + let client = MockClient::new().with("https://x/y", 200, "{\"ok\":true}"); + let resp = client.get("https://x/y").unwrap(); + assert_eq!(resp.status, 200); + assert!(resp.body.contains("ok")); + assert!(client.get("https://x/other").is_err()); + } +} diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 40a70d0..e87adac 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -8,6 +8,7 @@ pub mod ask_json; pub mod cli; +pub mod http; pub mod io; pub mod lockfiles; pub mod markers; From 1f075bae8a6cac402e90c818233304a4e24e9d74 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:27:31 +0900 Subject: [PATCH 16/44] feat(rust): port registry HTTP lookup (RegistrySource + resolve_from_registry) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Adds registry/api.rs over the injectable HttpClient: - RegistrySource: the npm|github|web|llms-txt discriminated union (serde tag="type", kebab-case variants so LlmsTxt→"llms-txt", camelCase fields so max_depth→maxDepth), ported from packages/schema/src/registry.ts. - RegistryApiResponse / RegistryApiPackage: the flattened API response shape. - fetch_registry_entry: URL-encodes the catch-all segments (scoped npm names), returns None on 404 / non-2xx (warning with the server statusMessage, e.g. the 409 monorepo guidance) / transport failure / unparseable body — parity with fetchRegistryEntry. - resolve_from_registry: ecosystem/name/version split (explicit prefix or detect_ecosystem), returns the primary source. registry.rs is now registry/{mod,api}.rs. 7 tests via MockClient (no network). 92 tests green. --- crates/ask/src/registry/api.rs | 288 ++++++++++++++++++ .../ask/src/{registry.rs => registry/mod.rs} | 21 +- 2 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 crates/ask/src/registry/api.rs rename crates/ask/src/{registry.rs => registry/mod.rs} (94%) diff --git a/crates/ask/src/registry/api.rs b/crates/ask/src/registry/api.rs new file mode 100644 index 0000000..483c001 --- /dev/null +++ b/crates/ask/src/registry/api.rs @@ -0,0 +1,288 @@ +//! Registry HTTP surface — `RegistrySource`, the flattened API response, and the +//! `fetch_registry_entry` / `resolve_from_registry` lookups. Rust port of the +//! network half of `registry.ts` (plus the `RegistrySource` type from +//! `packages/schema/src/registry.ts`). + +use serde::{Deserialize, Serialize}; + +use super::{detect_ecosystem, parse_ecosystem, REGISTRY_BASE_URL}; +use crate::http::{encode_uri_component, HttpClient}; + +/// A way to fetch one package's docs (discriminated on `type`). Mirrors the +/// `sourceSchema` discriminated union: `npm | github | web | llms-txt`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum RegistrySource { + Npm { + package: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + path: Option, + }, + Github { + repo: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tag: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + path: Option, + }, + Web { + urls: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + max_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + allowed_path_prefix: Option, + }, + LlmsTxt { + url: String, + }, +} + +impl RegistrySource { + /// The `type` discriminant as it appears on the wire. + pub fn type_name(&self) -> &'static str { + match self { + RegistrySource::Npm { .. } => "npm", + RegistrySource::Github { .. } => "github", + RegistrySource::Web { .. } => "web", + RegistrySource::LlmsTxt { .. } => "llms-txt", + } + } +} + +/// The single package block in a flattened registry API response. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RegistryApiPackage { + pub name: String, + #[serde(default)] + pub description: Option, +} + +/// Flattened registry API response — one registry entry focused on a single +/// package. `resolved_name` is the CLI-facing slug (`@mastra/core` → +/// `mastra-core`); `sources` is in the author's declared priority order. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistryApiResponse { + pub name: String, + pub description: String, + pub repo: String, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub tags: Option>, + pub resolved_name: String, + pub package: RegistryApiPackage, + pub sources: Vec, +} + +/// The result of resolving a spec against the registry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryResolution { + pub ecosystem: String, + pub name: String, + pub version: String, + pub source: RegistrySource, +} + +#[derive(Deserialize)] +struct StatusBody { + #[serde(rename = "statusMessage")] + status_message: Option, +} + +/// Fetch a registry entry from the registry API. Accepts either `(owner, repo)` +/// for a direct lookup or `(ecosystem, name)` for an alias lookup — the API +/// handles both via a catch-all slug. +/// +/// Returns `None` on 404, on any non-2xx (after warning with the server's +/// `statusMessage` — e.g. the 409 monorepo-disambiguation guidance), on a +/// transport failure/timeout, or on a body that does not parse. Parity with +/// `fetchRegistryEntry`. +pub fn fetch_registry_entry( + client: &dyn HttpClient, + first: &str, + second: &str, +) -> Option { + // `second` may contain `/` for scoped npm packages — encode it so the server + // sees a single catch-all segment. + let url = format!( + "{REGISTRY_BASE_URL}/api/registry/{}/{}", + encode_uri_component(first), + encode_uri_component(second), + ); + + let response = match client.get(&url) { + Ok(r) => r, + // Transport failure / timeout — treat as a miss, let downstream + // resolvers take over. + Err(_) => return None, + }; + + if response.status == 404 { + return None; + } + if !response.ok() { + let message = serde_json::from_str::(&response.body) + .ok() + .and_then(|b| b.status_message) + .unwrap_or_else(|| format!("HTTP {}", response.status)); + eprintln!( + "Registry lookup for {first}/{second} returned {}: {message}", + response.status + ); + return None; + } + + serde_json::from_str::(&response.body).ok() +} + +/// Resolve a spec against the registry: split the ecosystem/name/version, look +/// up the entry, and return its primary (first) source. Parity with +/// `resolveFromRegistry`. +pub fn resolve_from_registry( + client: &dyn HttpClient, + input: &str, + project_dir: &std::path::Path, +) -> Option { + let (explicit_ecosystem, spec) = parse_ecosystem(input); + + // Split a trailing `@version` (last `@`, but not a leading scope marker). + let (name, version) = match spec.rfind('@') { + Some(idx) if idx > 0 => (&spec[..idx], &spec[idx + 1..]), + _ => (spec, "latest"), + }; + + let ecosystem = explicit_ecosystem + .map(str::to_string) + .unwrap_or_else(|| detect_ecosystem(project_dir).to_string()); + + let entry = fetch_registry_entry(client, &ecosystem, name)?; + + let Some(primary) = entry.sources.first() else { + eprintln!("Registry entry for {name} has no sources"); + return None; + }; + + Some(RegistryResolution { + ecosystem, + name: entry.resolved_name.clone(), + version: version.to_string(), + source: primary.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + const BASE: &str = "https://ask-registry.pages.dev"; + + fn entry_json() -> &'static str { + r#"{ + "name": "next.js", + "description": "The React Framework", + "repo": "vercel/next.js", + "resolvedName": "next", + "package": { "name": "next" }, + "sources": [ + { "type": "npm", "package": "next", "path": "dist/docs" }, + { "type": "github", "repo": "vercel/next.js", "path": "docs" } + ] + }"# + } + + #[test] + fn source_discriminated_union_roundtrips() { + let src: RegistrySource = + serde_json::from_str(r#"{"type":"llms-txt","url":"https://x/llms.txt"}"#).unwrap(); + assert_eq!( + src, + RegistrySource::LlmsTxt { + url: "https://x/llms.txt".into() + } + ); + assert_eq!(src.type_name(), "llms-txt"); + // camelCase field on a web source. + let web: RegistrySource = serde_json::from_str( + r#"{"type":"web","urls":["https://x"],"maxDepth":2,"allowedPathPrefix":"/docs"}"#, + ) + .unwrap(); + assert!(matches!( + web, + RegistrySource::Web { + max_depth: Some(2), + .. + } + )); + } + + #[test] + fn fetch_entry_ok() { + let client = + MockClient::new().with(&format!("{BASE}/api/registry/npm/next"), 200, entry_json()); + let entry = fetch_registry_entry(&client, "npm", "next").unwrap(); + assert_eq!(entry.resolved_name, "next"); + assert_eq!(entry.sources.len(), 2); + assert_eq!(entry.sources[0].type_name(), "npm"); + } + + #[test] + fn fetch_entry_scoped_name_is_url_encoded() { + // `@mastra/client-js` → catch-all segment `%40mastra%2Fclient-js`. + let url = format!("{BASE}/api/registry/npm/%40mastra%2Fclient-js"); + let client = MockClient::new().with(&url, 200, entry_json()); + assert!(fetch_registry_entry(&client, "npm", "@mastra/client-js").is_some()); + } + + #[test] + fn fetch_entry_404_and_transport_error_are_none() { + let client = MockClient::new().with(&format!("{BASE}/api/registry/npm/missing"), 404, ""); + assert!(fetch_registry_entry(&client, "npm", "missing").is_none()); + // Unregistered URL → MockClient errors (transport failure) → None. + assert!(fetch_registry_entry(&client, "npm", "never").is_none()); + } + + #[test] + fn fetch_entry_non_ok_warns_and_returns_none() { + let client = MockClient::new().with( + &format!("{BASE}/api/registry/mastra-ai/mastra"), + 409, + r#"{"statusMessage":"Monorepo entry — use npm:@mastra/core"}"#, + ); + assert!(fetch_registry_entry(&client, "mastra-ai", "mastra").is_none()); + } + + #[test] + fn resolve_splits_version_and_returns_primary_source() { + let client = + MockClient::new().with(&format!("{BASE}/api/registry/npm/next"), 200, entry_json()); + let dir = tempfile::tempdir().unwrap(); + // Explicit ecosystem prefix + version. + let res = resolve_from_registry(&client, "npm:next@14.2.3", dir.path()).unwrap(); + assert_eq!(res.ecosystem, "npm"); + assert_eq!(res.name, "next"); + assert_eq!(res.version, "14.2.3"); + assert_eq!(res.source.type_name(), "npm"); + } + + #[test] + fn resolve_detects_ecosystem_from_project_when_prefix_absent() { + let client = + MockClient::new().with(&format!("{BASE}/api/registry/npm/next"), 200, entry_json()); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + // No prefix, no version → ecosystem from package.json, version "latest". + let res = resolve_from_registry(&client, "next", dir.path()).unwrap(); + assert_eq!(res.ecosystem, "npm"); + assert_eq!(res.version, "latest"); + } +} diff --git a/crates/ask/src/registry.rs b/crates/ask/src/registry/mod.rs similarity index 94% rename from crates/ask/src/registry.rs rename to crates/ask/src/registry/mod.rs index 2118f42..092e488 100644 --- a/crates/ask/src/registry.rs +++ b/crates/ask/src/registry/mod.rs @@ -1,12 +1,17 @@ -//! Registry spec parsing + ecosystem detection — Rust port of the pure parts of -//! `packages/cli/src/registry.ts`. +//! Registry spec parsing, ecosystem detection, and the registry HTTP lookup — +//! Rust port of `packages/cli/src/registry.ts`. //! -//! The HTTP surface (`fetchRegistryEntry` / `resolveFromRegistry`, bounded by a -//! 10s timeout against `https://ask-registry.pages.dev`) and the `RegistrySource` -//! response type are deferred to a later phase — they depend on the registry -//! schema (`@pleaseai/ask-schema`) and an HTTP client, ported together once the -//! registry-entry schema lands. The parsing/detection helpers here are pure and -//! unblock the resolver + sources layers. +//! The pure parsing/detection helpers live here; the HTTP surface +//! (`RegistrySource`, `RegistryApiResponse`, [`api::fetch_registry_entry`], +//! [`api::resolve_from_registry`], bounded by the shared 10s-timeout +//! [`crate::http::HttpClient`]) lives in the `api` submodule. + +pub mod api; + +pub use api::{ + fetch_registry_entry, resolve_from_registry, RegistryApiPackage, RegistryApiResponse, + RegistryResolution, RegistrySource, +}; use std::path::Path; From 4b029dbd8404a415f1e8d070184272fc6c28bd9c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:31:18 +0900 Subject: [PATCH 17/44] feat(rust): port ecosystem resolvers (npm/pypi/pub/maven) over HttpClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports resolvers/{index,npm,pypi,pub,maven}.ts: ResolveResult + get_resolver dispatch, and each resolver as a `fn(&dyn HttpClient, name, version) -> Result`. - npm: registry.npmjs.org metadata; dist-tag → semver-range best match (via the semver crate, matching validRange+maxSatisfying) → exact; repository string| {url,directory}; monorepo `@`/`@v` changesets fallbacks. - pypi: project_urls SOURCE_URL_KEYS scan → home_page fallback; v ref. - pub: pub.dev; inverted ref convention (bare version primary, v fallback). - maven: Search API (version + scm.url) → POM → POM , with a maven-metadata.xml fallback for latest; regex XML scraping via LazyLock. resolvers.rs is now resolvers/{mod,npm,pypi,pub_dev,maven}.rs. All network paths tested via MockClient (no live requests). 21 new tests, 113 total green. --- crates/ask/src/resolvers/maven.rs | 377 ++++++++++++++++++ .../src/{resolvers.rs => resolvers/mod.rs} | 48 ++- crates/ask/src/resolvers/npm.rs | 234 +++++++++++ crates/ask/src/resolvers/pub_dev.rs | 140 +++++++ crates/ask/src/resolvers/pypi.rs | 124 ++++++ 5 files changed, 917 insertions(+), 6 deletions(-) create mode 100644 crates/ask/src/resolvers/maven.rs rename crates/ask/src/{resolvers.rs => resolvers/mod.rs} (58%) create mode 100644 crates/ask/src/resolvers/npm.rs create mode 100644 crates/ask/src/resolvers/pub_dev.rs create mode 100644 crates/ask/src/resolvers/pypi.rs diff --git a/crates/ask/src/resolvers/maven.rs b/crates/ask/src/resolvers/maven.rs new file mode 100644 index 0000000..1017b2d --- /dev/null +++ b/crates/ask/src/resolvers/maven.rs @@ -0,0 +1,377 @@ +//! Maven Central ecosystem resolver — Rust port of `resolvers/maven.ts`. +//! +//! Resolution walks: Search API (version + optional `scm.url`) → POM `` +//! → POM ``, with a `maven-metadata.xml` fallback for the latest version +//! when the Search API is unavailable. + +use std::sync::LazyLock; + +use anyhow::{anyhow, bail}; +use regex::Regex; +use serde::Deserialize; + +use super::{parse_repo_url, ResolveResult}; +use crate::http::{encode_uri_component, HttpClient}; + +static RE_SCM_URL: LazyLock = + LazyLock::new(|| Regex::new(r"(?s).*?([^<]+)").unwrap()); +static RE_PROJECT_URL: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)]*>.*?([^<]+)").unwrap()); +static RE_RELEASE: LazyLock = + LazyLock::new(|| Regex::new(r"([^<]+)").unwrap()); +static RE_LATEST: LazyLock = + LazyLock::new(|| Regex::new(r"([^<]+)").unwrap()); + +#[derive(Deserialize)] +struct MavenSearchResponse { + response: MavenSearchInner, +} + +#[derive(Deserialize)] +struct MavenSearchInner { + #[serde(rename = "numFound")] + num_found: u64, + docs: Vec, +} + +#[derive(Deserialize)] +struct MavenSearchDoc { + v: String, +} + +#[derive(Deserialize)] +struct MavenScmResponse { + response: MavenScmInner, +} + +#[derive(Deserialize)] +struct MavenScmInner { + docs: Vec, +} + +#[derive(Deserialize)] +struct MavenScmDoc { + #[serde(rename = "scm.url")] + scm_url: Option, +} + +struct VersionResult { + version: String, + scm_url: Option, +} + +/// Split `groupId:artifactId` at the LAST colon (groupId may contain dots but +/// never colons; artifactId never contains colons). +fn parse_maven_coordinate(name: &str) -> anyhow::Result<(String, String)> { + match name.rfind(':') { + Some(idx) if idx > 0 && idx != name.len() - 1 => { + Ok((name[..idx].to_string(), name[idx + 1..].to_string())) + } + _ => bail!( + "Invalid Maven coordinate '{name}': expected 'groupId:artifactId' format \ + (e.g. 'com.google.guava:guava')" + ), + } +} + +fn build_pom_url(group_id: &str, artifact_id: &str, version: &str) -> String { + let group_path = group_id.replace('.', "/"); + format!("https://repo1.maven.org/maven2/{group_path}/{artifact_id}/{version}/{artifact_id}-{version}.pom") +} + +/// Extract a GitHub repo URL from POM XML: `` first, then top-level +/// ``. +fn extract_repo_from_pom(pom_xml: &str) -> Option { + if let Some(caps) = RE_SCM_URL.captures(pom_xml) { + if let Some(repo) = parse_repo_url(Some(caps[1].trim())) { + return Some(repo); + } + } + if let Some(caps) = RE_PROJECT_URL.captures(pom_xml) { + if let Some(repo) = parse_repo_url(Some(caps[1].trim())) { + return Some(repo); + } + } + None +} + +/// Resolve a Maven Central package to a GitHub repo + git ref (port of +/// `MavenResolver`). +pub fn resolve( + client: &dyn HttpClient, + name: &str, + version: &str, +) -> anyhow::Result { + let (group_id, artifact_id) = parse_maven_coordinate(name)?; + + let version_result = resolve_version(client, &group_id, &artifact_id, version)?; + let resolved_version = version_result.version; + + // Repo: (1) Search API scm.url, (2/3) POM XML. + let mut repo = version_result + .scm_url + .as_deref() + .and_then(|u| parse_repo_url(Some(u))); + if repo.is_none() { + repo = find_repo_from_pom(client, &group_id, &artifact_id, &resolved_version); + } + let repo = repo.ok_or_else(|| { + anyhow!( + "Cannot resolve GitHub repository for Maven package '{group_id}:{artifact_id}'. \ + Neither the Search API nor the POM contains a GitHub URL. Use 'github:owner/repo' \ + format instead: ask add github:owner/repo --ref " + ) + })?; + + Ok(ResolveResult { + repo, + ref_: format!("v{resolved_version}"), + fallback_refs: vec![resolved_version.clone()], + resolved_version, + }) +} + +/// Resolve version (+ optional scm.url). Explicit versions skip Search-API +/// validation (the POM fetch fails if absent) but still try it for the scm.url; +/// `latest` falls back to `maven-metadata.xml` when the Search API is down. +fn resolve_version( + client: &dyn HttpClient, + group_id: &str, + artifact_id: &str, + version: &str, +) -> anyhow::Result { + if version != "latest" { + return Ok( + fetch_search_api(client, group_id, artifact_id, version).unwrap_or_else(|_| { + VersionResult { + version: version.to_string(), + scm_url: None, + } + }), + ); + } + match fetch_search_api(client, group_id, artifact_id, version) { + Ok(vr) => Ok(vr), + Err(_) => resolve_version_from_metadata(client, group_id, artifact_id), + } +} + +fn fetch_search_api( + client: &dyn HttpClient, + group_id: &str, + artifact_id: &str, + version: &str, +) -> anyhow::Result { + let is_latest = version == "latest"; + let (g, a) = ( + encode_uri_component(group_id), + encode_uri_component(artifact_id), + ); + let query = if is_latest { + format!("q=g:{g}+AND+a:{a}&rows=1&wt=json") + } else { + let v = encode_uri_component(version); + format!("q=g:{g}+AND+a:{a}+AND+v:{v}&rows=1&wt=json&core=gav") + }; + let url = format!("https://search.maven.org/solrsearch/select?{query}"); + let response = client.get(&url)?; + if !response.ok() { + bail!( + "Maven Central Search API returned {} for {group_id}:{artifact_id}", + response.status + ); + } + let data: MavenSearchResponse = serde_json::from_str(&response.body)?; + if data.response.num_found == 0 { + let suffix = if is_latest { + String::new() + } else { + format!("@{version}") + }; + bail!("Maven package '{group_id}:{artifact_id}'{suffix} not found on Maven Central"); + } + let doc_version = data + .response + .docs + .into_iter() + .next() + .map(|d| d.v) + .ok_or_else(|| { + anyhow!("Maven Central Search API returned no docs for {group_id}:{artifact_id}") + })?; + + // Best-effort scm.url from the artifact-level (non-GAV) core. + let scm_url = fetch_scm_url(client, group_id, artifact_id).ok().flatten(); + + Ok(VersionResult { + version: doc_version, + scm_url, + }) +} + +fn fetch_scm_url( + client: &dyn HttpClient, + group_id: &str, + artifact_id: &str, +) -> anyhow::Result> { + let (g, a) = ( + encode_uri_component(group_id), + encode_uri_component(artifact_id), + ); + let url = + format!("https://search.maven.org/solrsearch/select?q=g:{g}+AND+a:{a}&rows=1&wt=json"); + let response = client.get(&url)?; + if !response.ok() { + return Ok(None); + } + let data: MavenScmResponse = serde_json::from_str(&response.body)?; + Ok(data + .response + .docs + .into_iter() + .next() + .and_then(|d| d.scm_url)) +} + +fn resolve_version_from_metadata( + client: &dyn HttpClient, + group_id: &str, + artifact_id: &str, +) -> anyhow::Result { + let group_path = group_id.replace('.', "/"); + let url = + format!("https://repo1.maven.org/maven2/{group_path}/{artifact_id}/maven-metadata.xml"); + let response = client.get(&url)?; + if !response.ok() { + bail!( + "Cannot resolve Maven package '{group_id}:{artifact_id}': Search API unavailable and \ + maven-metadata.xml returned {}", + response.status + ); + } + let version = RE_RELEASE + .captures(&response.body) + .or_else(|| RE_LATEST.captures(&response.body)) + .map(|c| c[1].to_string()) + .ok_or_else(|| { + anyhow!( + "Cannot resolve latest version for Maven package '{group_id}:{artifact_id}': \ + no or tag in maven-metadata.xml" + ) + })?; + Ok(VersionResult { + version, + scm_url: None, + }) +} + +fn find_repo_from_pom( + client: &dyn HttpClient, + group_id: &str, + artifact_id: &str, + version: &str, +) -> Option { + let pom_url = build_pom_url(group_id, artifact_id, version); + let response = client.get(&pom_url).ok()?; + if response.ok() { + return extract_repo_from_pom(&response.body); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + #[test] + fn parse_coordinate_valid_and_invalid() { + assert_eq!( + parse_maven_coordinate("com.google.guava:guava").unwrap(), + ("com.google.guava".to_string(), "guava".to_string()) + ); + assert!(parse_maven_coordinate("noguava").is_err()); + assert!(parse_maven_coordinate("g:").is_err()); + assert!(parse_maven_coordinate(":a").is_err()); + } + + #[test] + fn build_pom_url_converts_group_dots() { + assert_eq!( + build_pom_url("com.google.guava", "guava", "33.0.0"), + "https://repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.pom" + ); + } + + #[test] + fn extract_repo_prefers_scm_over_project_url() { + let pom = r#"https://github.com/proj/sitehttps://github.com/real/repo"#; + assert_eq!(extract_repo_from_pom(pom).as_deref(), Some("real/repo")); + let pom_no_scm = r#"https://github.com/o/r"#; + assert_eq!(extract_repo_from_pom(pom_no_scm).as_deref(), Some("o/r")); + } + + #[test] + fn resolve_via_search_api_scm_url() { + let latest = + "https://search.maven.org/solrsearch/select?q=g:com.example+AND+a:lib&rows=1&wt=json"; + let client = MockClient::new() + .with(latest, 200, r#"{"response":{"numFound":1,"docs":[{"v":"1.2.3","scm.url":"https://github.com/example/lib"}]}}"#); + let r = resolve(&client, "com.example:lib", "latest").unwrap(); + assert_eq!(r.repo, "example/lib"); + assert_eq!(r.resolved_version, "1.2.3"); + assert_eq!(r.ref_, "v1.2.3"); + assert_eq!(r.fallback_refs, vec!["1.2.3"]); + } + + #[test] + fn resolve_explicit_version_falls_back_to_pom() { + // Search API is down (unregistered → transport error), explicit version + // is used as-is, and the repo comes from the POM. + let pom = build_pom_url("com.example", "lib", "9.9.9"); + let client = MockClient::new().with( + &pom, + 200, + r#"https://github.com/example/lib"#, + ); + let r = resolve(&client, "com.example:lib", "9.9.9").unwrap(); + assert_eq!(r.repo, "example/lib"); + assert_eq!(r.resolved_version, "9.9.9"); + } + + #[test] + fn latest_falls_back_to_metadata_when_search_down() { + // Search API url is unregistered (transport error) → metadata.xml. + let meta = "https://repo1.maven.org/maven2/com/example/lib/maven-metadata.xml"; + let pom = build_pom_url("com.example", "lib", "4.5.6"); + let client = MockClient::new() + .with( + meta, + 200, + "4.5.6", + ) + .with( + &pom, + 200, + r#"https://github.com/example/lib"#, + ); + let r = resolve(&client, "com.example:lib", "latest").unwrap(); + assert_eq!(r.resolved_version, "4.5.6"); + assert_eq!(r.repo, "example/lib"); + } + + #[test] + fn no_repo_anywhere_errors() { + let latest = + "https://search.maven.org/solrsearch/select?q=g:com.example+AND+a:lib&rows=1&wt=json"; + let pom = build_pom_url("com.example", "lib", "1.0.0"); + let client = MockClient::new() + .with( + latest, + 200, + r#"{"response":{"numFound":1,"docs":[{"v":"1.0.0"}]}}"#, + ) + .with(&pom, 200, ""); + assert!(resolve(&client, "com.example:lib", "latest").is_err()); + } +} diff --git a/crates/ask/src/resolvers.rs b/crates/ask/src/resolvers/mod.rs similarity index 58% rename from crates/ask/src/resolvers.rs rename to crates/ask/src/resolvers/mod.rs index 1a9a873..da13c16 100644 --- a/crates/ask/src/resolvers.rs +++ b/crates/ask/src/resolvers/mod.rs @@ -1,15 +1,51 @@ -//! Ecosystem resolvers — turn package metadata into a GitHub `owner/repo`. -//! Rust port of `packages/cli/src/resolvers/`. +//! Ecosystem resolvers — map a package name + version to a GitHub `owner/repo` +//! plus a git ref, over the injectable [`crate::http::HttpClient`]. Rust port of +//! `packages/cli/src/resolvers/`. //! -//! **Port status:** the shared [`parse_repo_url`] helper (resolvers/utils.ts) is -//! ported. The per-ecosystem resolvers (npm/pypi/pub/maven) fetch registry -//! metadata over HTTP and are deferred to the phase that introduces the HTTP -//! client (ureq), alongside the registry HTTP surface. +//! Resolvers are orthogonal to sources: they only do metadata lookups and hand +//! the resolved `repo` + `ref` to the github source. The npm/pypi/pub resolvers +//! parse JSON; maven walks the Search API + POM XML. + +mod maven; +mod npm; +mod pub_dev; +mod pypi; use std::sync::LazyLock; use regex::Regex; +use crate::http::HttpClient; + +/// Result of resolving an ecosystem package to a GitHub repository. Always +/// handed off to the github source for download (port of `ResolveResult`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolveResult { + /// GitHub `owner/repo`. + pub repo: String, + /// Primary git ref to try first (tag or branch). + pub ref_: String, + /// Fallback refs to try if the primary ref doesn't exist (empty = none). + pub fallback_refs: Vec, + /// Resolved version string (e.g. `4.17.21`). + pub resolved_version: String, +} + +/// An ecosystem resolver function: `(client, name, version) -> ResolveResult`. +pub type ResolverFn = fn(&dyn HttpClient, &str, &str) -> anyhow::Result; + +/// Return the resolver for the given ecosystem, or `None` if unsupported +/// (port of `getResolver`). +pub fn get_resolver(ecosystem: &str) -> Option { + match ecosystem { + "maven" => Some(maven::resolve), + "npm" => Some(npm::resolve), + "pypi" => Some(pypi::resolve), + "pub" => Some(pub_dev::resolve), + _ => None, + } +} + // `github.com` followed by `/` or `:`, then owner (no `/`), `/`, then repo up to // the first `/`, `#`, `?`, or whitespace. Mirrors the TS `RE_GITHUB_URL`. static RE_GITHUB_URL: LazyLock = diff --git a/crates/ask/src/resolvers/npm.rs b/crates/ask/src/resolvers/npm.rs new file mode 100644 index 0000000..f0f0747 --- /dev/null +++ b/crates/ask/src/resolvers/npm.rs @@ -0,0 +1,234 @@ +//! npm ecosystem resolver — Rust port of `resolvers/npm.ts`. + +use anyhow::{anyhow, bail}; +use serde::Deserialize; + +use super::{parse_repo_url, ResolveResult}; +use crate::http::HttpClient; + +/// npm registry metadata (partial — only the fields we read). +#[derive(Deserialize)] +struct NpmPackageMeta { + #[serde(default)] + repository: Option, + #[serde(rename = "dist-tags", default)] + dist_tags: std::collections::BTreeMap, + #[serde(default)] + versions: Option>, +} + +/// `repository` is either a bare URL string or `{ url, directory }`. +#[derive(Deserialize)] +#[serde(untagged)] +enum RepoField { + Str(String), + Obj { + #[serde(default)] + url: Option, + #[serde(default)] + directory: Option, + }, +} + +impl RepoField { + fn url(&self) -> Option<&str> { + match self { + RepoField::Str(s) => Some(s), + RepoField::Obj { url, .. } => url.as_deref(), + } + } + + fn directory(&self) -> Option<&str> { + match self { + RepoField::Str(_) => None, + RepoField::Obj { directory, .. } => directory.as_deref(), + } + } +} + +/// Whether a version string is a semver *range* (a valid requirement that +/// actually contains a range operator), matching the TS +/// `validRange(v) && v !== v.replace(/[~^>=<|]/g, '')`. +fn is_semver_range(version: &str) -> bool { + let has_range_char = version + .chars() + .any(|c| matches!(c, '~' | '^' | '>' | '=' | '<' | '|')); + has_range_char && semver::VersionReq::parse(version).is_ok() +} + +/// Highest version in `all_versions` satisfying `range`, or `None`. +fn max_satisfying(all_versions: &[String], range: &str) -> Option { + let req = semver::VersionReq::parse(range).ok()?; + all_versions + .iter() + .filter_map(|v| semver::Version::parse(v).ok().map(|parsed| (parsed, v))) + .filter(|(parsed, _)| req.matches(parsed)) + .max_by(|(a, _), (b, _)| a.cmp(b)) + .map(|(_, raw)| raw.clone()) +} + +/// Resolve an npm package to a GitHub repo + git ref (port of `NpmResolver`). +/// +/// 1. Fetch `registry.npmjs.org/`. +/// 2. Resolve version: dist-tag → semver-range best match → exact passthrough. +/// 3. Extract `repository.url` → `owner/repo`. +/// 4. Return `v` as the primary ref (with monorepo `@` +/// fallbacks when `repository.directory` is present). +pub fn resolve( + client: &dyn HttpClient, + name: &str, + version: &str, +) -> anyhow::Result { + let url = format!("https://registry.npmjs.org/{name}"); + let response = client.get(&url)?; + if !response.ok() { + bail!("npm registry returned {} for {name}", response.status); + } + let meta: NpmPackageMeta = serde_json::from_str(&response.body)?; + + let all_versions: Vec = meta + .versions + .as_ref() + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + + let resolved_version = if let Some(tagged) = meta.dist_tags.get(version) { + tagged.clone() + } else if is_semver_range(version) { + max_satisfying(&all_versions, version).ok_or_else(|| { + anyhow!( + "No version matching '{version}' found for npm package '{name}'. \ + Available dist-tags: {}", + dist_tag_keys(&meta), + ) + })? + } else { + version.to_string() + }; + + if !all_versions.is_empty() && !all_versions.contains(&resolved_version) { + bail!( + "Version '{resolved_version}' not found for npm package '{name}'. \ + Available dist-tags: {}", + dist_tag_keys(&meta), + ); + } + + let repo_field = meta.repository.as_ref(); + let repo = parse_repo_url(repo_field.and_then(RepoField::url)).ok_or_else(|| { + anyhow!( + "Cannot resolve GitHub repository for npm package '{name}'. The 'repository' field is \ + missing or not a GitHub URL. Use 'github:owner/repo' format instead: \ + ask add github:owner/repo --ref " + ) + })?; + + // Monorepo packages (repository.directory present) use changesets-style + // `@` / `@v` tags; scoped names use the unscoped + // part (`@vercel/ai` → `ai`). + let mut fallback_refs = Vec::new(); + if repo_field.and_then(RepoField::directory).is_some() { + let unscoped = if let Some(rest) = name.strip_prefix('@') { + rest.split_once('/').map(|(_, n)| n).unwrap_or(rest) + } else { + name + }; + fallback_refs.push(format!("{unscoped}@{resolved_version}")); + fallback_refs.push(format!("{unscoped}@v{resolved_version}")); + } + fallback_refs.push(resolved_version.clone()); + + Ok(ResolveResult { + repo, + ref_: format!("v{resolved_version}"), + fallback_refs, + resolved_version, + }) +} + +fn dist_tag_keys(meta: &NpmPackageMeta) -> String { + meta.dist_tags + .keys() + .cloned() + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + const URL: &str = "https://registry.npmjs.org/next"; + + fn meta_json() -> &'static str { + r#"{ + "dist-tags": { "latest": "15.0.3", "canary": "15.1.0-canary.1" }, + "versions": { "14.2.3": {}, "15.0.0": {}, "15.0.3": {}, "15.1.0-canary.1": {} }, + "repository": { "type": "git", "url": "git+https://github.com/vercel/next.js.git" } + }"# + } + + #[test] + fn resolves_dist_tag() { + let c = MockClient::new().with(URL, 200, meta_json()); + let r = resolve(&c, "next", "latest").unwrap(); + assert_eq!(r.repo, "vercel/next.js"); + assert_eq!(r.resolved_version, "15.0.3"); + assert_eq!(r.ref_, "v15.0.3"); + assert_eq!(r.fallback_refs, vec!["15.0.3"]); + } + + #[test] + fn resolves_semver_range_to_best_match() { + let c = MockClient::new().with(URL, 200, meta_json()); + let r = resolve(&c, "next", "^15.0.0").unwrap(); + // ^15.0.0 excludes the 14.x and the prerelease → 15.0.3. + assert_eq!(r.resolved_version, "15.0.3"); + } + + #[test] + fn exact_version_passthrough() { + let c = MockClient::new().with(URL, 200, meta_json()); + let r = resolve(&c, "next", "14.2.3").unwrap(); + assert_eq!(r.resolved_version, "14.2.3"); + assert_eq!(r.ref_, "v14.2.3"); + } + + #[test] + fn monorepo_directory_adds_scoped_fallbacks() { + let meta = r#"{ + "dist-tags": { "latest": "5.0.0" }, + "versions": { "5.0.0": {} }, + "repository": { "url": "https://github.com/vercel/ai.git", "directory": "packages/ai" } + }"#; + let c = MockClient::new().with("https://registry.npmjs.org/@vercel/ai", 200, meta); + let r = resolve(&c, "@vercel/ai", "latest").unwrap(); + assert_eq!(r.repo, "vercel/ai"); + assert_eq!(r.fallback_refs, vec!["ai@5.0.0", "ai@v5.0.0", "5.0.0"]); + } + + #[test] + fn repository_as_bare_string() { + let meta = r#"{ + "dist-tags": { "latest": "1.0.0" }, + "versions": { "1.0.0": {} }, + "repository": "https://github.com/owner/repo" + }"#; + let c = MockClient::new().with("https://registry.npmjs.org/pkg", 200, meta); + assert_eq!(resolve(&c, "pkg", "latest").unwrap().repo, "owner/repo"); + } + + #[test] + fn non_github_repository_errors() { + let meta = r#"{"dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{}},"repository":"https://gitlab.com/o/r"}"#; + let c = MockClient::new().with("https://registry.npmjs.org/pkg", 200, meta); + assert!(resolve(&c, "pkg", "latest").is_err()); + } + + #[test] + fn http_error_propagates() { + let c = MockClient::new().with(URL, 404, ""); + assert!(resolve(&c, "next", "latest").is_err()); + } +} diff --git a/crates/ask/src/resolvers/pub_dev.rs b/crates/ask/src/resolvers/pub_dev.rs new file mode 100644 index 0000000..94c6972 --- /dev/null +++ b/crates/ask/src/resolvers/pub_dev.rs @@ -0,0 +1,140 @@ +//! pub.dev ecosystem resolver — Rust port of `resolvers/pub.ts`. +//! (Module named `pub_dev` because `pub` is a Rust keyword; the ecosystem string +//! is still `"pub"`.) + +use anyhow::{anyhow, bail}; +use serde::Deserialize; + +use super::{parse_repo_url, ResolveResult}; +use crate::http::HttpClient; + +#[derive(Deserialize)] +struct PubPackageMeta { + latest: PubLatest, + #[serde(default)] + versions: Option>, +} + +#[derive(Deserialize)] +struct PubLatest { + version: String, + pubspec: PubPubspec, +} + +#[derive(Deserialize)] +struct PubPubspec { + #[serde(default)] + repository: Option, + #[serde(default)] + homepage: Option, +} + +#[derive(Deserialize)] +struct PubVersion { + version: String, +} + +/// Resolve a pub.dev package to a GitHub repo + git ref (port of `PubResolver`). +/// Note the ref convention is inverted vs npm/pypi: the primary ref is the bare +/// version and `v` is the fallback. +pub fn resolve( + client: &dyn HttpClient, + name: &str, + version: &str, +) -> anyhow::Result { + let url = format!("https://pub.dev/api/packages/{name}"); + let response = client.get(&url)?; + if !response.ok() { + bail!("pub.dev returned {} for {name}", response.status); + } + + let meta: PubPackageMeta = serde_json::from_str(&response.body)?; + let latest_version = meta.latest.version; + + let resolved_version = if version == "latest" { + latest_version.clone() + } else { + let all: Vec<&str> = meta + .versions + .iter() + .flatten() + .map(|v| v.version.as_str()) + .collect(); + if !all.is_empty() && !all.contains(&version) { + bail!( + "Version '{version}' not found for pub package '{name}'. \ + Latest version: {latest_version}" + ); + } + version.to_string() + }; + + let repo_url = meta + .latest + .pubspec + .repository + .or(meta.latest.pubspec.homepage); + let repo = parse_repo_url(repo_url.as_deref()).ok_or_else(|| { + anyhow!( + "Cannot resolve GitHub repository for pub package '{name}'. The 'repository' field is \ + missing or not a GitHub URL. Use 'github:owner/repo' format instead: \ + ask add github:owner/repo --ref " + ) + })?; + + Ok(ResolveResult { + repo, + ref_: resolved_version.clone(), + fallback_refs: vec![format!("v{resolved_version}")], + resolved_version, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + const URL: &str = "https://pub.dev/api/packages/riverpod"; + + fn meta_json() -> &'static str { + r#"{ + "latest": { "version": "2.5.1", "pubspec": { "repository": "https://github.com/rrousselGit/riverpod" } }, + "versions": [ { "version": "2.5.0" }, { "version": "2.5.1" } ] + }"# + } + + #[test] + fn resolves_latest_with_inverted_ref_convention() { + let c = MockClient::new().with(URL, 200, meta_json()); + let r = resolve(&c, "riverpod", "latest").unwrap(); + assert_eq!(r.repo, "rrousselGit/riverpod"); + assert_eq!(r.resolved_version, "2.5.1"); + // Primary ref is the bare version; v-prefixed is the fallback. + assert_eq!(r.ref_, "2.5.1"); + assert_eq!(r.fallback_refs, vec!["v2.5.1"]); + } + + #[test] + fn explicit_existing_version_ok() { + let c = MockClient::new().with(URL, 200, meta_json()); + assert_eq!( + resolve(&c, "riverpod", "2.5.0").unwrap().resolved_version, + "2.5.0" + ); + } + + #[test] + fn explicit_missing_version_errors() { + let c = MockClient::new().with(URL, 200, meta_json()); + assert!(resolve(&c, "riverpod", "9.9.9").is_err()); + } + + #[test] + fn falls_back_to_homepage() { + let meta = + r#"{"latest":{"version":"1.0.0","pubspec":{"homepage":"https://github.com/o/r"}}}"#; + let c = MockClient::new().with("https://pub.dev/api/packages/x", 200, meta); + assert_eq!(resolve(&c, "x", "latest").unwrap().repo, "o/r"); + } +} diff --git a/crates/ask/src/resolvers/pypi.rs b/crates/ask/src/resolvers/pypi.rs new file mode 100644 index 0000000..a52d132 --- /dev/null +++ b/crates/ask/src/resolvers/pypi.rs @@ -0,0 +1,124 @@ +//! PyPI ecosystem resolver — Rust port of `resolvers/pypi.ts`. + +use anyhow::{anyhow, bail}; +use serde::Deserialize; + +use super::{parse_repo_url, ResolveResult}; +use crate::http::HttpClient; + +#[derive(Deserialize)] +struct PypiPackageMeta { + info: PypiInfo, +} + +#[derive(Deserialize)] +struct PypiInfo { + version: String, + #[serde(default)] + project_urls: Option>, + #[serde(default)] + home_page: Option, +} + +/// Keys under `project_urls` most likely to hold a source-code link, in order. +const SOURCE_URL_KEYS: &[&str] = &[ + "Source", + "Source Code", + "Repository", + "GitHub", + "Code", + "Homepage", +]; + +/// Resolve a PyPI package to a GitHub repo + git ref (port of `PypiResolver`). +pub fn resolve( + client: &dyn HttpClient, + name: &str, + version: &str, +) -> anyhow::Result { + let is_explicit = version != "latest"; + let url = if is_explicit { + format!("https://pypi.org/pypi/{name}/{version}/json") + } else { + format!("https://pypi.org/pypi/{name}/json") + }; + + let response = client.get(&url)?; + if !response.ok() { + let suffix = if is_explicit { + format!("@{version}") + } else { + String::new() + }; + bail!("PyPI returned {} for {name}{suffix}", response.status); + } + + let meta: PypiPackageMeta = serde_json::from_str(&response.body)?; + let resolved_version = meta.info.version; + + let project_urls = meta.info.project_urls.unwrap_or_default(); + let mut repo_url: Option<&str> = None; + for key in SOURCE_URL_KEYS { + if let Some(candidate) = project_urls.get(*key) { + if candidate.contains("github.com") { + repo_url = Some(candidate); + break; + } + } + } + // Fall back to home_page. + if repo_url.is_none() { + if let Some(hp) = &meta.info.home_page { + if hp.contains("github.com") { + repo_url = Some(hp); + } + } + } + + let repo = parse_repo_url(repo_url).ok_or_else(|| { + anyhow!( + "Cannot resolve GitHub repository for PyPI package '{name}'. The 'project_urls' field \ + does not contain a GitHub URL. Use 'github:owner/repo' format instead: \ + ask add github:owner/repo --ref " + ) + })?; + + Ok(ResolveResult { + repo, + ref_: format!("v{resolved_version}"), + fallback_refs: vec![resolved_version.clone()], + resolved_version, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + #[test] + fn resolves_from_project_urls() { + let meta = r#"{"info":{"version":"2.5.0","project_urls":{"Source":"https://github.com/psf/requests"}}}"#; + let c = MockClient::new().with("https://pypi.org/pypi/requests/json", 200, meta); + let r = resolve(&c, "requests", "latest").unwrap(); + assert_eq!(r.repo, "psf/requests"); + assert_eq!(r.ref_, "v2.5.0"); + assert_eq!(r.fallback_refs, vec!["2.5.0"]); + } + + #[test] + fn explicit_version_uses_versioned_url_and_falls_back_to_home_page() { + let meta = r#"{"info":{"version":"1.0.0","home_page":"https://github.com/o/r"}}"#; + let c = MockClient::new().with("https://pypi.org/pypi/pkg/1.0.0/json", 200, meta); + let r = resolve(&c, "pkg", "1.0.0").unwrap(); + assert_eq!(r.repo, "o/r"); + } + + #[test] + fn no_github_url_errors() { + let meta = + r#"{"info":{"version":"1.0.0","project_urls":{"Docs":"https://readthedocs.io"}}}"#; + let c = MockClient::new().with("https://pypi.org/pypi/pkg/json", 200, meta); + assert!(resolve(&c, "pkg", "latest").is_err()); + } +} From 6af2b7369432296b77b5146440fd4e89e061d9b9 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:36:02 +0900 Subject: [PATCH 18/44] feat(rust): port the global store (paths, atomic writes, locking, verify) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports store/index.ts as store/{paths,mod}.rs — the foundation every source adapter writes through. - paths.rs: resolve_ask_home (ASK_HOME tilde-expand → ~/.ask), npm/github/web/ llms-txt store paths, assert_contained (lexical_clean-based containment) + assert_safe_segment traversal guards, normalize_url (scheme+host lowercase, path case-preserved). Legacy github/db + github/checkouts paths intentionally omitted per CLAUDE.md. - mod.rs: write_entry_atomic + cp_dir_atomic (temp-dir + backup-rename swap; cp preserves symlinks VERBATIM via read_link+symlink — the gitbutler broken- symlink gotcha), acquire_entry_lock (create_new lock file + backoff, RAII EntryLock, hit-detection returns None), stamp/verify_entry + hash_dir (sha256 over sorted `\0\0`, skips .ask-hash), STORE_VERSION r/w, quarantine_entry. walkdir promoted to a normal dep; tempfile stays dev-only (prod temp names are hand-rolled). 13 new tests incl. a unix verbatim-symlink test. 126 total green. --- crates/ask/Cargo.toml | 1 + crates/ask/src/lib.rs | 1 + crates/ask/src/store/mod.rs | 426 ++++++++++++++++++++++++++++++++++ crates/ask/src/store/paths.rs | 239 +++++++++++++++++++ 4 files changed, 667 insertions(+) create mode 100644 crates/ask/src/store/mod.rs create mode 100644 crates/ask/src/store/paths.rs diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml index 22b82cb..cd7be27 100644 --- a/crates/ask/Cargo.toml +++ b/crates/ask/Cargo.toml @@ -36,6 +36,7 @@ semver = { workspace = true } sha2 = { workspace = true } time = { workspace = true } ureq = { workspace = true } +walkdir = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index e87adac..8fbd963 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -16,6 +16,7 @@ pub mod registry; pub mod resolved; pub mod resolvers; pub mod spec; +pub mod store; use std::fmt; diff --git a/crates/ask/src/store/mod.rs b/crates/ask/src/store/mod.rs new file mode 100644 index 0000000..9783eac --- /dev/null +++ b/crates/ask/src/store/mod.rs @@ -0,0 +1,426 @@ +//! Global ASK store — atomic writes, entry locking, content verification, the +//! store-version marker, and quarantine. Rust port of `store/index.ts`. +//! +//! The `github/db` and `github/checkouts` legacy layouts are intentionally NOT +//! reintroduced (see CLAUDE.md); only cache cleanup references them by name. + +mod paths; + +pub use paths::{ + assert_contained, assert_safe_segment, github_store_path, lexical_clean, llms_txt_store_path, + normalize_url, npm_store_path, resolve_ask_home, web_store_path, +}; + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant, SystemTime}; + +use sha2::{Digest, Sha256}; + +const HASH_FILE: &str = ".ask-hash"; +const STORE_VERSION_FILE: &str = "STORE_VERSION"; +const CURRENT_STORE_VERSION: &str = "2"; + +/// A file to write into a store entry: a relative path and its text content. +pub struct EntryFile { + pub path: String, + pub content: String, +} + +/// A short random-ish hex suffix for temp/backup directory names. Uniqueness is +/// only needed to avoid same-process collisions (the entry lock serializes real +/// writers), so SystemTime nanos XOR a process-lifetime counter suffices. +fn rand_hex8() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let n = nanos + ^ (COUNTER + .fetch_add(1, Ordering::Relaxed) + .wrapping_mul(0x9E37_79B9_7F4A_7C15)); + format!("{:08x}", (n as u32)) +} + +/// Atomically swap `tmp_dir` into `target`: rename any existing target to a +/// backup first (keeping the path continuously observable), move tmp into place, +/// then remove the backup. Restores the original on a failed move. +fn atomic_swap(tmp_dir: &Path, target: &Path) -> std::io::Result<()> { + if target.exists() { + let backup = sibling(target, ".bak-"); + std::fs::rename(target, &backup)?; + if let Err(e) = std::fs::rename(tmp_dir, target) { + let _ = std::fs::rename(&backup, target); + return Err(e); + } + let _ = std::fs::remove_dir_all(&backup); + } else { + std::fs::rename(tmp_dir, target)?; + } + Ok(()) +} + +fn sibling(target: &Path, infix: &str) -> PathBuf { + let name = target + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + target.with_file_name(format!("{name}{infix}{}", rand_hex8())) +} + +/// Atomically write a directory of files to `target_dir` (temp dir + rename). +/// Each file path is containment-checked so a malicious archive entry +/// (`../../etc/passwd`) cannot escape. +pub fn write_entry_atomic(target_dir: &Path, files: &[EntryFile]) -> anyhow::Result<()> { + let tmp_dir = sibling(target_dir, ".tmp-"); + std::fs::create_dir_all(&tmp_dir)?; + let result = (|| -> anyhow::Result<()> { + for file in files { + let file_path = tmp_dir.join(&file.path); + assert_contained(&tmp_dir, &file_path)?; + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&file_path, &file.content)?; + } + atomic_swap(&tmp_dir, target_dir)?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_dir_all(&tmp_dir); + } + result +} + +/// Atomically copy a directory tree to `target_dir`, preserving symlinks +/// **verbatim** (not resolving them against the source). Without this, relative +/// symlinks would bake in absolute paths pointing at the soon-deleted clone tmp +/// dir, breaking every later `verify_entry` with ENOENT (the gitbutler CLAUDE.md +/// symlink gotcha). +pub fn cp_dir_atomic(source_dir: &Path, target_dir: &Path) -> anyhow::Result<()> { + let tmp_dir = sibling(target_dir, ".tmp-"); + if let Some(parent) = tmp_dir.parent() { + std::fs::create_dir_all(parent)?; + } + let result = (|| -> anyhow::Result<()> { + copy_tree_verbatim(source_dir, &tmp_dir)?; + atomic_swap(&tmp_dir, target_dir)?; + Ok(()) + })(); + if result.is_err() { + let _ = std::fs::remove_dir_all(&tmp_dir); + } + result +} + +/// Recursively copy `src` into `dst`, copying symlinks as symlinks (verbatim +/// target) rather than following them. +fn copy_tree_verbatim(src: &Path, dst: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if file_type.is_symlink() { + let target = std::fs::read_link(&from)?; + symlink_verbatim(&target, &to)?; + } else if file_type.is_dir() { + copy_tree_verbatim(&from, &to)?; + } else { + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn symlink_verbatim(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(not(unix))] +fn symlink_verbatim(target: &Path, link: &Path) -> std::io::Result<()> { + // Windows requires privileges/target-type for symlinks; fall back to copying + // the resolved file (best-effort; the store gotchas this guards are unix). + std::fs::copy(target, link).map(|_| ()) +} + +// ── Entry locking ────────────────────────────────────────────────── + +const LOCK_TIMEOUT: Duration = Duration::from_secs(60); +const LOCK_INITIAL_DELAY: Duration = Duration::from_millis(100); +const LOCK_MAX_DELAY: Duration = Duration::from_millis(1600); + +/// An acquired entry lock; removes its lock file on drop. +pub struct EntryLock { + lock_path: PathBuf, +} + +impl Drop for EntryLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.lock_path); + } +} + +/// Acquire a per-entry advisory lock (`.lock`, exclusive-create) with +/// exponential backoff. Returns `Ok(None)` when the target entry appears while +/// waiting (another process finished the write — treat as a hit). Errors on +/// timeout without deleting the live lock. +pub fn acquire_entry_lock(entry_dir: &Path) -> anyhow::Result> { + let lock_path = { + let name = entry_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + entry_dir.with_file_name(format!("{name}.lock")) + }; + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let deadline = Instant::now() + LOCK_TIMEOUT; + let mut delay = LOCK_INITIAL_DELAY; + + loop { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(_) => return Ok(Some(EntryLock { lock_path })), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // Held elsewhere. If the target now exists, another process + // finished — treat as a store hit. + if entry_dir.exists() { + return Ok(None); + } + if Instant::now() >= deadline { + anyhow::bail!( + "Timed out waiting for lock: {}. Another ask install may be in progress. \ + If no other process is running, remove it manually.", + lock_path.display() + ); + } + std::thread::sleep(delay); + delay = (delay * 2).min(LOCK_MAX_DELAY); + } + Err(e) => return Err(e.into()), + } + } +} + +// ── Content verification ─────────────────────────────────────────── + +/// Compute and record a content hash for a finalized entry (`.ask-hash`). +pub fn stamp_entry(entry_dir: &Path) -> anyhow::Result { + let hash = hash_dir(entry_dir)?; + std::fs::write(entry_dir.join(HASH_FILE), &hash)?; + Ok(hash) +} + +/// Whether an entry's recorded `.ask-hash` matches its recomputed hash. +pub fn verify_entry(entry_dir: &Path) -> bool { + let hash_path = entry_dir.join(HASH_FILE); + let Ok(recorded) = std::fs::read_to_string(&hash_path) else { + return false; + }; + match hash_dir(entry_dir) { + Ok(actual) => recorded.trim() == actual, + Err(_) => false, + } +} + +/// sha256 over the sorted relative file list, `\0\0` per file, +/// skipping the `.ask-hash` file itself. +fn hash_dir(dir: &Path) -> anyhow::Result { + let mut files = collect_files(dir, "")?; + files.sort(); + let mut hasher = Sha256::new(); + for rel in &files { + if Path::new(rel).file_name().and_then(|n| n.to_str()) == Some(HASH_FILE) { + continue; + } + hasher.update(rel.as_bytes()); + hasher.update([0u8]); + hasher.update(std::fs::read(dir.join(rel))?); + hasher.update([0u8]); + } + Ok(format!("sha256-{:x}", hasher.finalize())) +} + +/// Relative file paths under `dir` (recursive, forward-slash separated). +fn collect_files(dir: &Path, prefix: &str) -> anyhow::Result> { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + let rel = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if entry.file_type()?.is_dir() { + out.extend(collect_files(&entry.path(), &rel)?); + } else { + out.push(rel); + } + } + Ok(out) +} + +// ── Store version ────────────────────────────────────────────────── + +/// Write `/STORE_VERSION` if absent or stale (best-effort). +pub fn write_store_version(ask_home: &Path) { + let file = ask_home.join(STORE_VERSION_FILE); + let _ = std::fs::create_dir_all(ask_home); + let existing = std::fs::read_to_string(&file) + .ok() + .map(|s| s.trim().to_string()); + if existing.as_deref() != Some(CURRENT_STORE_VERSION) { + let _ = std::fs::write(&file, format!("{CURRENT_STORE_VERSION}\n")); + } +} + +/// Read `/STORE_VERSION`, or `None` for a fresh/pre-v2 store. +pub fn read_store_version(ask_home: &Path) -> Option { + std::fs::read_to_string(ask_home.join(STORE_VERSION_FILE)) + .ok() + .map(|s| s.trim().to_string()) +} + +// ── Quarantine ───────────────────────────────────────────────────── + +/// Move a corrupt entry (one failing `verify_entry`) to +/// `/.quarantine/-/` for inspection; a rename failure falls +/// back to a best-effort remove. +pub fn quarantine_entry(ask_home: &Path, store_dir: &Path) { + let ts = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let quarantine_dir = ask_home + .join(".quarantine") + .join(format!("{ts}-{}", rand_hex8())); + if let Some(parent) = quarantine_dir.parent() { + let _ = std::fs::create_dir_all(parent); + } + if std::fs::rename(store_dir, &quarantine_dir).is_err() { + let _ = std::fs::remove_dir_all(store_dir); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ef(path: &str, content: &str) -> EntryFile { + EntryFile { + path: path.into(), + content: content.into(), + } + } + + #[test] + fn write_then_verify_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("entry"); + write_entry_atomic(&target, &[ef("INDEX.md", "hi"), ef("sub/a.md", "x")]).unwrap(); + assert_eq!( + std::fs::read_to_string(target.join("INDEX.md")).unwrap(), + "hi" + ); + assert_eq!( + std::fs::read_to_string(target.join("sub/a.md")).unwrap(), + "x" + ); + + stamp_entry(&target).unwrap(); + assert!(verify_entry(&target)); + // Tamper → verification fails. + std::fs::write(target.join("INDEX.md"), "changed").unwrap(); + assert!(!verify_entry(&target)); + } + + #[test] + fn write_entry_rejects_escaping_path() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("entry"); + assert!(write_entry_atomic(&target, &[ef("../escape.md", "x")]).is_err()); + } + + #[test] + fn write_entry_overwrites_existing_atomically() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("entry"); + write_entry_atomic(&target, &[ef("a.md", "1")]).unwrap(); + write_entry_atomic(&target, &[ef("b.md", "2")]).unwrap(); + // The second write replaced the first entirely. + assert!(!target.join("a.md").exists()); + assert_eq!(std::fs::read_to_string(target.join("b.md")).unwrap(), "2"); + } + + #[cfg(unix)] + #[test] + fn cp_dir_preserves_symlinks_verbatim() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("AGENTS.md"), "agents").unwrap(); + std::os::unix::fs::symlink("AGENTS.md", src.join("CLAUDE.md")).unwrap(); + + let target = dir.path().join("dst"); + cp_dir_atomic(&src, &target).unwrap(); + let link = target.join("CLAUDE.md"); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + // The relative target is preserved verbatim (not absolutized). + assert_eq!(std::fs::read_link(&link).unwrap(), Path::new("AGENTS.md")); + assert_eq!(std::fs::read_to_string(&link).unwrap(), "agents"); + } + + #[test] + fn lock_acquire_release_and_hit() { + let dir = tempfile::tempdir().unwrap(); + let entry = dir.path().join("e"); + let lock = acquire_entry_lock(&entry).unwrap().unwrap(); + assert!(dir.path().join("e.lock").exists()); + drop(lock); + assert!(!dir.path().join("e.lock").exists()); + + // Lock held + target exists → treated as a hit (None). + std::fs::create_dir_all(&entry).unwrap(); + let _held = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(dir.path().join("e.lock")) + .unwrap(); + assert!(acquire_entry_lock(&entry).unwrap().is_none()); + } + + #[test] + fn store_version_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(read_store_version(dir.path()), None); + write_store_version(dir.path()); + assert_eq!(read_store_version(dir.path()).as_deref(), Some("2")); + } + + #[test] + fn quarantine_moves_entry_out() { + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().join("npm").join("bad@1.0.0"); + std::fs::create_dir_all(&store).unwrap(); + std::fs::write(store.join("x"), "y").unwrap(); + quarantine_entry(dir.path(), &store); + assert!(!store.exists()); + assert!(dir + .path() + .join(".quarantine") + .read_dir() + .unwrap() + .next() + .is_some()); + } +} diff --git a/crates/ask/src/store/paths.rs b/crates/ask/src/store/paths.rs new file mode 100644 index 0000000..33f24bb --- /dev/null +++ b/crates/ask/src/store/paths.rs @@ -0,0 +1,239 @@ +//! Store path resolution + containment guards — Rust port of the path helpers in +//! `packages/cli/src/store/index.ts`. + +use std::path::{Component, Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// Resolve the global ASK home directory. Precedence: `ASK_HOME` (tilde-expanded, +/// absolutized) → `~/.ask`. +pub fn resolve_ask_home() -> PathBuf { + if let Some(env_val) = std::env::var_os("ASK_HOME").filter(|v| !v.is_empty()) { + let raw = env_val.to_string_lossy(); + let expanded: PathBuf = if let Some(rest) = raw.strip_prefix("~/") { + home_dir().join(rest) + } else { + PathBuf::from(raw.as_ref()) + }; + return absolutize(&expanded); + } + home_dir().join(".ask") +} + +/// Best-effort home directory (`$HOME` on unix, `%USERPROFILE%` on Windows). +fn home_dir() -> PathBuf { + #[cfg(windows)] + let key = "USERPROFILE"; + #[cfg(not(windows))] + let key = "HOME"; + std::env::var_os(key) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Lexically clean a path (resolve `.`/`..` without touching the filesystem), +/// then make it absolute against the current dir if it is relative. Mirrors the +/// normalization `path.resolve` performs before containment checks. +fn absolutize(p: &Path) -> PathBuf { + let base = if p.is_absolute() { + p.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(p) + }; + lexical_clean(&base) +} + +/// Go-style lexical `Clean`: collapse `.` and `..` segments without resolving +/// symlinks or hitting the filesystem. +pub fn lexical_clean(p: &Path) -> PathBuf { + let mut out: Vec = Vec::new(); + for comp in p.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => match out.last() { + Some(Component::Normal(_)) => { + out.pop(); + } + Some(Component::RootDir) | Some(Component::Prefix(_)) => {} + _ => out.push(comp), + }, + other => out.push(other), + } + } + out.iter().map(|c| c.as_os_str()).collect() +} + +/// Assert `candidate` resolves inside `parent`; returns the cleaned candidate. +/// Prevents path traversal via `..` or absolute paths in user-controlled inputs. +pub fn assert_contained(parent: &Path, candidate: &Path) -> anyhow::Result { + let rp = absolutize(parent); + let rc = absolutize(candidate); + if rc == rp || rc.starts_with(&rp) { + Ok(rc) + } else { + anyhow::bail!( + "Unsafe path: {} is outside {}", + candidate.display(), + parent.display() + ); + } +} + +/// Reject a path segment containing `..`, `/`, `\`, or empty — segment-level +/// traversal that [`assert_contained`] alone cannot catch. +pub fn assert_safe_segment(name: &str, value: &str) -> anyhow::Result<()> { + if value.is_empty() || value.contains("..") || value.contains('/') || value.contains('\\') { + anyhow::bail!("Unsafe path: {name} '{value}' contains path traversal characters"); + } + Ok(()) +} + +pub fn npm_store_path(ask_home: &Path, pkg: &str, version: &str) -> anyhow::Result { + let candidate = ask_home.join("npm").join(format!("{pkg}@{version}")); + assert_contained(ask_home, &candidate) +} + +/// The PM-style nested layout for a github entry: +/// `/github/////`. +pub fn github_store_path( + ask_home: &Path, + host: &str, + owner: &str, + repo: &str, + tag: &str, +) -> anyhow::Result { + assert_safe_segment("host", host)?; + assert_safe_segment("owner", owner)?; + assert_safe_segment("repo", repo)?; + assert_safe_segment("tag", tag)?; + let github_root = ask_home.join("github"); + let candidate = github_root.join(host).join(owner).join(repo).join(tag); + assert_contained(&github_root, &candidate) +} + +pub fn web_store_path(ask_home: &Path, url: &str) -> PathBuf { + ask_home.join("web").join(hash_url(url)) +} + +pub fn llms_txt_store_path(ask_home: &Path, url: &str, version: &str) -> PathBuf { + ask_home + .join("llms-txt") + .join(format!("{}@{version}", hash_url(url))) +} + +fn hash_url(url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(normalize_url(url).as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// Normalize a URL for hashing: strip trailing slashes, lowercase the +/// scheme + host (the path stays case-sensitive per RFC 3986). Non-parseable +/// strings are lowercased wholesale. +pub fn normalize_url(url: &str) -> String { + let stripped = url.trim_end_matches('/'); + // Minimal scheme://host split — enough to lowercase scheme+authority + // without a URL-parsing dependency. + if let Some(scheme_end) = stripped.find("://") { + let scheme = &stripped[..scheme_end]; + let rest = &stripped[scheme_end + 3..]; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + // Authority may carry userinfo@host:port; lowercase the whole authority + // (host is the case-relevant part; userinfo/port are ASCII-safe here). + format!( + "{}://{}{}", + scheme.to_lowercase(), + authority.to_lowercase(), + path + ) + } else { + stripped.to_lowercase() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ask_home_env_and_default() { + // Absolute ASK_HOME is used verbatim (cleaned). + temp_env("ASK_HOME", Some("/tmp/custom/ask"), || { + assert_eq!(resolve_ask_home(), PathBuf::from("/tmp/custom/ask")); + }); + temp_env("ASK_HOME", None, || { + assert!(resolve_ask_home().ends_with(".ask")); + }); + } + + #[test] + fn npm_and_github_paths() { + let home = Path::new("/store"); + assert_eq!( + npm_store_path(home, "next", "15.0.3").unwrap(), + PathBuf::from("/store/npm/next@15.0.3") + ); + assert_eq!( + github_store_path(home, "github.com", "vercel", "next.js", "v15.0.3").unwrap(), + PathBuf::from("/store/github/github.com/vercel/next.js/v15.0.3") + ); + } + + #[test] + fn github_path_rejects_traversal_segments() { + let home = Path::new("/store"); + assert!(github_store_path(home, "github.com", "..", "repo", "v1").is_err()); + assert!(github_store_path(home, "github.com", "o", "re/po", "v1").is_err()); + assert!(github_store_path(home, "github.com", "o", "repo", "").is_err()); + } + + #[test] + fn assert_contained_blocks_escape() { + let parent = Path::new("/store/github"); + assert!(assert_contained(parent, Path::new("/store/github/a/b")).is_ok()); + assert!(assert_contained(parent, Path::new("/store/github/../../etc")).is_err()); + assert!(assert_contained(parent, Path::new("/etc/passwd")).is_err()); + } + + #[test] + fn normalize_url_lowercases_scheme_host_only() { + assert_eq!( + normalize_url("HTTPS://Example.COM/Docs/"), + "https://example.com/Docs" + ); + assert_eq!(normalize_url("https://X.io///"), "https://x.io"); + // Path case is preserved. + assert_eq!( + normalize_url("https://x.io/CaseSensitive"), + "https://x.io/CaseSensitive" + ); + } + + #[test] + fn llms_txt_and_web_paths_hash_normalized_url() { + let home = Path::new("/store"); + // Trailing slash + host case fold to the same hash. + let a = llms_txt_store_path(home, "https://X.io/llms.txt", "1"); + let b = llms_txt_store_path(home, "https://x.io/llms.txt/", "1"); + assert_eq!(a, b); + assert!(web_store_path(home, "https://x.io").starts_with("/store/web/")); + } + + fn temp_env(key: &str, value: Option<&str>, f: impl FnOnce()) { + let prev = std::env::var_os(key); + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + f(); + match prev { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } +} From e8ffb01946ba1eb070a1a2be98e955ed3dfeb8ea Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:46:52 +0900 Subject: [PATCH 19/44] feat(rust): port source adapters (github, npm, llms-txt) + shared collect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports sources/{github,npm,llms-txt}.ts as sources/{mod,github,npm,llms_txt}.rs (web deferred — loose html→md parity). - mod.rs: DocFile/FetchResult/FetchMeta value types; is_doc_file, collect_doc_ files (md/mdx/txt/rst, sorted), detect_docs_path, extract_docs_from_dir (realpath containment). NPM_/GITHUB_DOC_CANDIDATES. - github.rs: authenticated_clone_url (opensrc #66 host-exact token injection via the url crate) + redact_token + ref_candidates dedup — the security-critical pure helpers, fully unit-tested. git SHELL-OUT clone-at-tag with ref fallback + ls-remote tag probe, .git strip + commit-SHA capture, store-hit verify/ quarantine short-circuit, tar.gz fallback (flate2+tar, get_bytes w/ Bearer auth for private repos). End-to-end tested against a local bare repo (offline). - npm.rs: local-first try_local_read (node_modules, node-semver version_matches — bare version = exact, NOT Cargo caret) + tarball path (npm view + get_bytes + extract). Traversal + realpath guards. - llms_txt.rs: fetch + filename derivation + store write. HttpClient gains get_bytes(url, headers) → BytesResponse (50 MiB cap) for archive downloads; MockClient gains with_bytes. New deps: url, tar, flate2; tempfile promoted to a normal dep (RAII temp dirs for clone/extract). 28 new tests, 154 total green. --- Cargo.toml | 1 + crates/ask/Cargo.toml | 5 +- crates/ask/src/http.rs | 58 ++- crates/ask/src/lib.rs | 1 + crates/ask/src/sources/github.rs | 790 +++++++++++++++++++++++++++++ crates/ask/src/sources/llms_txt.rs | 137 +++++ crates/ask/src/sources/mod.rs | 255 ++++++++++ crates/ask/src/sources/npm.rs | 364 +++++++++++++ 8 files changed, 1605 insertions(+), 6 deletions(-) create mode 100644 crates/ask/src/sources/github.rs create mode 100644 crates/ask/src/sources/llms_txt.rs create mode 100644 crates/ask/src/sources/mod.rs create mode 100644 crates/ask/src/sources/npm.rs diff --git a/Cargo.toml b/Cargo.toml index 770b46b..cba218f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ walkdir = "2" ignore = "0.4" tar = "0.4" flate2 = "1" +url = "2" # HTTP client for registry / npm / github metadata (rustls, no OpenSSL system dep). ureq = { version = "3", features = ["json"] } diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml index cd7be27..e2c1b20 100644 --- a/crates/ask/Cargo.toml +++ b/crates/ask/Cargo.toml @@ -37,6 +37,7 @@ sha2 = { workspace = true } time = { workspace = true } ureq = { workspace = true } walkdir = { workspace = true } - -[dev-dependencies] +url = { workspace = true } +tar = { workspace = true } +flate2 = { workspace = true } tempfile = { workspace = true } diff --git a/crates/ask/src/http.rs b/crates/ask/src/http.rs index abe1ebe..aee8714 100644 --- a/crates/ask/src/http.rs +++ b/crates/ask/src/http.rs @@ -25,11 +25,32 @@ impl HttpResponse { } } -/// A GET-only HTTP client. `get` performs the request and reads the full body; -/// a transport failure (DNS, connect, timeout, read) is an `Err`, while any -/// HTTP status (including 4xx/5xx) is an `Ok(HttpResponse)`. +/// A completed HTTP response with a binary body (for archive downloads). +#[derive(Debug, Clone)] +pub struct BytesResponse { + pub status: u16, + pub body: Vec, +} + +impl BytesResponse { + /// `true` for a 2xx status (parity with `fetch`'s `response.ok`). + pub fn ok(&self) -> bool { + (200..300).contains(&self.status) + } +} + +/// Upper bound on a downloaded archive body (50 MiB) — well above any docs +/// tarball, but a guard against a runaway/hostile response. +const MAX_ARCHIVE_BYTES: u64 = 50 * 1024 * 1024; + +/// A GET-only HTTP client. `get` reads the body as text; `get_bytes` reads it as +/// raw bytes with optional request headers (used for GitHub/npm tarball +/// downloads). A transport failure (DNS, connect, timeout, read) is an `Err`, +/// while any HTTP status (including 4xx/5xx) is an `Ok`. pub trait HttpClient { fn get(&self, url: &str) -> anyhow::Result; + + fn get_bytes(&self, url: &str, headers: &[(&str, &str)]) -> anyhow::Result; } /// The production client: a ureq agent with a 10s global timeout (parity with @@ -63,6 +84,21 @@ impl HttpClient for UreqClient { let body = response.body_mut().read_to_string()?; Ok(HttpResponse { status, body }) } + + fn get_bytes(&self, url: &str, headers: &[(&str, &str)]) -> anyhow::Result { + let mut request = self.agent.get(url); + for (name, value) in headers { + request = request.header(*name, *value); + } + let mut response = request.call()?; + let status = response.status().as_u16(); + let body = response + .body_mut() + .with_config() + .limit(MAX_ARCHIVE_BYTES) + .read_to_vec()?; + Ok(BytesResponse { status, body }) + } } /// Percent-encode a string the way JavaScript's `encodeURIComponent` does: @@ -95,11 +131,12 @@ pub fn encode_uri_component(input: &str) -> String { pub mod mock { use std::collections::HashMap; - use super::{HttpClient, HttpResponse}; + use super::{BytesResponse, HttpClient, HttpResponse}; #[derive(Default)] pub struct MockClient { responses: HashMap, + bytes: HashMap, } impl MockClient { @@ -118,6 +155,13 @@ pub mod mock { ); self } + + /// Register a canned binary response for an exact URL. + pub fn with_bytes(mut self, url: &str, status: u16, body: Vec) -> Self { + self.bytes + .insert(url.to_string(), BytesResponse { status, body }); + self + } } impl HttpClient for MockClient { @@ -127,6 +171,12 @@ pub mod mock { .cloned() .ok_or_else(|| anyhow::anyhow!("MockClient: no response registered for {url}")) } + + fn get_bytes(&self, url: &str, _headers: &[(&str, &str)]) -> anyhow::Result { + self.bytes.get(url).cloned().ok_or_else(|| { + anyhow::anyhow!("MockClient: no bytes response registered for {url}") + }) + } } } diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 8fbd963..dd061de 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -15,6 +15,7 @@ pub mod markers; pub mod registry; pub mod resolved; pub mod resolvers; +pub mod sources; pub mod spec; pub mod store; diff --git a/crates/ask/src/sources/github.rs b/crates/ask/src/sources/github.rs new file mode 100644 index 0000000..6f14378 --- /dev/null +++ b/crates/ask/src/sources/github.rs @@ -0,0 +1,790 @@ +//! GitHub source — shallow-clone a ref into the global store (tar.gz fallback), +//! with ref-candidate fallbacks and token auth. Rust port of `sources/github.ts`. +//! +//! Clones happen via a `git` SHELL-OUT (not a git library) so the opensrc +//! `authenticated_clone_url` token injection + `redact_token` scrubbing port +//! verbatim, including the #66 host-confusion fix. + +use std::io::Cursor; +use std::path::Path; +use std::process::Command; +use std::sync::LazyLock; + +use anyhow::{bail, Result}; +use flate2::read::GzDecoder; +use regex::Regex; +use url::Url; + +use super::{extract_docs_from_dir, FetchMeta, FetchResult, GITHUB_DOC_CANDIDATES}; +use crate::http::HttpClient; +use crate::store::{ + acquire_entry_lock, cp_dir_atomic, github_store_path, quarantine_entry, stamp_entry, + verify_entry, +}; + +const DEFAULT_GITHUB_HOST: &str = "github.com"; + +// ASCII-only classes to match the JS `\w` (`[A-Za-z0-9_]`), not Rust's +// unicode-aware `\w`. +static RE_SAFE_REPO: LazyLock = + LazyLock::new(|| Regex::new(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$").unwrap()); +static RE_SAFE_REF: LazyLock = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9._/@-]+$").unwrap()); +static RE_SHA40: LazyLock = LazyLock::new(|| Regex::new(r"^[0-9a-f]{40}$").unwrap()); + +/// Default-branch fallbacks tried (after `main`) when neither tag nor branch was +/// given. +const DEFAULT_BRANCH_FALLBACKS: &[&str] = &["master"]; + +/// Options for a github fetch (port of `GithubSourceOptions`). +#[derive(Debug, Clone, Default)] +pub struct GithubOptions { + pub name: String, + pub version: String, + pub repo: String, + pub branch: Option, + pub tag: Option, + pub docs_path: Option, + pub fallback_refs: Vec, + pub remote_url: Option, + pub skip_doc_extraction: bool, +} + +/// The `GITHUB_TOKEN` from the environment, empty string treated as absent. +pub fn github_token() -> Option { + std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty()) +} + +/// Rewrite an HTTPS GitHub clone URL to embed `x-access-token:` auth, but +/// ONLY on an exact `github.com` host over `https` (opensrc #66: prefix/suffix- +/// confusable hosts, subdomains, non-https schemes, and ssh remotes pass through +/// unchanged). The result carries the secret — use it only as a git argument, +/// never in logs. +pub fn authenticated_clone_url(url: &str, token: Option<&str>) -> String { + let token = match token { + Some(t) if !t.is_empty() => t, + _ => return url.to_string(), + }; + let Ok(mut parsed) = Url::parse(url) else { + return url.to_string(); + }; + if parsed.scheme() != "https" { + return url.to_string(); + } + if parsed.host_str().map(str::to_ascii_lowercase).as_deref() != Some(DEFAULT_GITHUB_HOST) { + return url.to_string(); + } + if parsed.set_username("x-access-token").is_err() || parsed.set_password(Some(token)).is_err() { + return url.to_string(); + } + parsed.to_string() +} + +/// Replace every occurrence of the token with `***` before logging/throwing. +fn redact_token(msg: &str, token: Option<&str>) -> String { + match token { + Some(t) if !t.is_empty() => msg.replace(t, "***"), + _ => msg.to_string(), + } +} + +/// Build the ref candidate chain: the ref as-is, plus `v` when it isn't +/// already `v`-prefixed. `extra` (monorepo tags) prepend; `tail` (default-branch +/// fallbacks) append; duplicates dropped (first wins). +fn ref_candidates(reference: &str, extra: &[String], tail: &[String]) -> Vec { + let base: Vec = if reference.starts_with('v') { + vec![reference.to_string()] + } else { + vec![reference.to_string(), format!("v{reference}")] + }; + if extra.is_empty() && tail.is_empty() { + return base; + } + let mut seen = std::collections::HashSet::new(); + let mut result = Vec::new(); + for c in extra.iter().chain(base.iter()).chain(tail.iter()) { + if seen.insert(c.clone()) { + result.push(c.clone()); + } + } + result +} + +fn has_git() -> bool { + Command::new("git") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Remove the contents of `dir` (keeping `dir`) so a failed clone can be retried. +fn clear_dir_contents(dir: &Path) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let _ = std::fs::remove_dir_all(entry.path()) + .or_else(|_| std::fs::remove_file(entry.path())); + } + } +} + +/// Shallow-clone a single candidate ref into `tmp_dir`, capture the commit SHA, +/// then strip `.git/`. Returns the commit SHA or an error (token-redacted). +fn shallow_clone_ref( + remote_url: &str, + candidate: &str, + tmp_dir: &Path, + token: Option<&str>, +) -> Result { + clear_dir_contents(tmp_dir); + let auth_url = authenticated_clone_url(remote_url, token); + let output = Command::new("git") + .args([ + "clone", + "--depth", + "1", + "--branch", + candidate, + "--single-branch", + ]) + .arg(&auth_url) + .arg(tmp_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("{}", redact_token(stderr.trim(), token)); + } + + let rev = Command::new("git") + .args(["-C"]) + .arg(tmp_dir) + .args(["rev-parse", "HEAD"]) + .output()?; + let commit = String::from_utf8_lossy(&rev.stdout).trim().to_string(); + if !RE_SHA40.is_match(&commit) { + bail!("git rev-parse returned invalid SHA '{commit}'"); + } + + let _ = std::fs::remove_dir_all(tmp_dir.join(".git")); + Ok(commit) +} + +/// Probe `git ls-remote --tags` for a tag containing `version`, preferring an +/// exact `@` / `@v` (changesets) suffix. +fn probe_remote_tag( + remote_url: &str, + version: &str, + token: Option<&str>, +) -> Option<(String, Vec)> { + let auth_url = authenticated_clone_url(remote_url, token); + let output = Command::new("git") + .args(["ls-remote", "--tags"]) + .arg(&auth_url) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let mut seen = std::collections::HashSet::new(); + let mut matching = Vec::new(); + for line in text.lines() { + // Strip everything up to and including `refs/tags/`, and a peeled `^{}`. + let Some(idx) = line.find("refs/tags/") else { + continue; + }; + let tag = line[idx + "refs/tags/".len()..].trim_end_matches("^{}"); + if tag.is_empty() || !tag.contains(version) { + continue; + } + if seen.insert(tag.to_string()) { + matching.push(tag.to_string()); + } + } + if matching.is_empty() { + return None; + } + let exact = matching + .iter() + .find(|t| t.ends_with(&format!("@{version}")) || t.ends_with(&format!("@v{version}"))) + .cloned(); + Some((exact.unwrap_or_else(|| matching[0].clone()), matching)) +} + +/// Try each ref candidate; on total failure probe remote tags (tag requests +/// only). Returns `(commit, winning_candidate)`. +fn clone_at_tag( + remote_url: &str, + reference: &str, + tmp_dir: &Path, + extra: &[String], + tag_only: bool, + tail: &[String], + token: Option<&str>, +) -> Result<(String, String)> { + let candidates = ref_candidates(reference, extra, tail); + let mut last_err = String::from("no candidates"); + for candidate in &candidates { + match shallow_clone_ref(remote_url, candidate, tmp_dir, token) { + Ok(commit) => return Ok((commit, candidate.clone())), + Err(e) => last_err = e.to_string(), + } + } + + if !tag_only { + bail!( + "Failed to clone {remote_url} at {reference} (tried: {}): {last_err}", + candidates.join(", ") + ); + } + + let version_for_probe = reference.strip_prefix('v').unwrap_or(reference); + if let Some((discovered, all_matching)) = probe_remote_tag(remote_url, version_for_probe, token) + { + match shallow_clone_ref(remote_url, &discovered, tmp_dir, token) { + Ok(commit) => return Ok((commit, discovered)), + Err(clone_err) => bail!( + "Failed to clone {remote_url} at {reference} (tried: {}). Available tags matching \ + '{version_for_probe}': {}. Clone error: {clone_err}. Retry with: \ + ask src github:@{discovered}", + candidates.join(", "), + all_matching.join(", ") + ), + } + } + + bail!( + "Failed to clone {remote_url} at {reference} (tried: {}): {last_err}", + candidates.join(", ") + ); +} + +/// Fetch docs for a github entry, materializing the checkout into the store. +pub fn fetch( + client: &dyn HttpClient, + opts: &GithubOptions, + ask_home: &Path, +) -> Result { + let is_default_ref = opts.tag.is_none() && opts.branch.is_none(); + let reference = opts + .tag + .clone() + .or_else(|| opts.branch.clone()) + .unwrap_or_else(|| "main".into()); + let (owner, repo_name) = opts + .repo + .split_once('/') + .ok_or_else(|| anyhow::anyhow!("Invalid repo '{}': must be owner/repo", opts.repo))?; + + if !RE_SAFE_REPO.is_match(&opts.repo) { + bail!( + "Invalid repo '{}': must be owner/repo with safe characters", + opts.repo + ); + } + if !RE_SAFE_REF.is_match(&reference) { + bail!("Invalid ref '{reference}': must contain only [A-Za-z0-9._/@-]"); + } + + let tag_version = opts + .tag + .as_deref() + .map(|t| t.strip_prefix('v').unwrap_or(t).to_string()); + let resolved_version = tag_version.unwrap_or_else(|| opts.version.clone()); + let tail: Vec = if is_default_ref { + DEFAULT_BRANCH_FALLBACKS + .iter() + .map(|s| s.to_string()) + .collect() + } else { + Vec::new() + }; + + // Store-hit path: check each candidate key, verify, quarantine on tamper. + for candidate in ref_candidates(&reference, &opts.fallback_refs, &tail) { + let store_dir = + github_store_path(ask_home, DEFAULT_GITHUB_HOST, owner, repo_name, &candidate)?; + if !store_dir.exists() { + continue; + } + if verify_entry(&store_dir) { + let files = if opts.skip_doc_extraction { + Vec::new() + } else { + extract_docs_from_dir( + &store_dir, + &opts.repo, + &reference, + opts.docs_path.as_deref(), + GITHUB_DOC_CANDIDATES, + )? + }; + return Ok(FetchResult { + files, + resolved_version, + store_path: Some(store_dir), + store_subpath: opts.docs_path.clone(), + from_store_cache: true, + meta: FetchMeta { + ref_: Some(reference.clone()), + ..Default::default() + }, + }); + } + quarantine_entry(ask_home, &store_dir); + } + + let remote_url = opts + .remote_url + .clone() + .unwrap_or_else(|| format!("https://github.com/{}.git", opts.repo)); + + if has_git() { + match fetch_via_shallow_clone( + opts, + owner, + repo_name, + &reference, + &resolved_version, + &remote_url, + ask_home, + &tail, + ) { + Ok(result) => return Ok(result), + Err(e) => { + eprintln!( + " Warning: git clone failed for {}@{reference}: {e}. Falling back to tar.gz download.", + opts.repo + ); + } + } + } + + fetch_from_tar_gz( + client, + opts, + owner, + repo_name, + &reference, + &resolved_version, + ask_home, + &tail, + ) +} + +#[allow(clippy::too_many_arguments)] +fn fetch_via_shallow_clone( + opts: &GithubOptions, + owner: &str, + repo_name: &str, + reference: &str, + resolved_version: &str, + remote_url: &str, + ask_home: &Path, + tail: &[String], +) -> Result { + let token = github_token(); + let tmp = tempfile::Builder::new().prefix("ask-gh-clone-").tempdir()?; + let (commit, winning) = clone_at_tag( + remote_url, + reference, + tmp.path(), + &opts.fallback_refs, + opts.tag.is_some(), + tail, + token.as_deref(), + )?; + let store_dir = github_store_path(ask_home, DEFAULT_GITHUB_HOST, owner, repo_name, &winning)?; + + if let Some(lock) = acquire_entry_lock(&store_dir)? { + if let Some(parent) = store_dir.parent() { + std::fs::create_dir_all(parent)?; + } + cp_dir_atomic(tmp.path(), &store_dir)?; + stamp_entry(&store_dir)?; + drop(lock); + } + + let files = if opts.skip_doc_extraction { + Vec::new() + } else { + extract_docs_from_dir( + &store_dir, + &opts.repo, + reference, + opts.docs_path.as_deref(), + GITHUB_DOC_CANDIDATES, + )? + }; + Ok(FetchResult { + files, + resolved_version: resolved_version.to_string(), + store_path: Some(store_dir), + store_subpath: opts.docs_path.clone(), + from_store_cache: false, + meta: FetchMeta { + commit: Some(commit), + ref_: Some(winning), + ..Default::default() + }, + }) +} + +#[allow(clippy::too_many_arguments)] +fn fetch_from_tar_gz( + client: &dyn HttpClient, + opts: &GithubOptions, + owner: &str, + repo_name: &str, + reference: &str, + resolved_version: &str, + ask_home: &Path, + tail: &[String], +) -> Result { + let token = github_token(); + let candidates = ref_candidates(reference, &opts.fallback_refs, tail); + let tmp = tempfile::Builder::new().prefix("ask-gh-tar-").tempdir()?; + let mut last_err = String::from("no candidates"); + + for candidate in &candidates { + let is_tag_candidate = opts.tag.is_some() && !tail.iter().any(|t| t == candidate); + let archive_url = if token.is_some() { + format!( + "https://api.github.com/repos/{}/tarball/{candidate}", + opts.repo + ) + } else { + let kind = if is_tag_candidate { "tags" } else { "heads" }; + format!( + "https://github.com/{}/archive/refs/{kind}/{candidate}.tar.gz", + opts.repo + ) + }; + let auth_header = token + .as_ref() + .map(|t| ("Authorization", format!("Bearer {t}"))); + let headers: Vec<(&str, &str)> = auth_header + .as_ref() + .map(|(k, v)| vec![(*k, v.as_str())]) + .unwrap_or_default(); + + let response = match client.get_bytes(&archive_url, &headers) { + Ok(r) => r, + Err(e) => { + last_err = e.to_string(); + continue; + } + }; + if !response.ok() { + last_err = format!("Failed to download {archive_url}: HTTP {}", response.status); + continue; + } + + clear_dir_contents(tmp.path()); + if let Err(e) = unpack_tar_gz(&response.body, tmp.path()) { + last_err = format!("tar extraction failed for {}@{candidate}: {e}", opts.repo); + clear_dir_contents(tmp.path()); + continue; + } + let extracted_dir = match std::fs::read_dir(tmp.path())?.flatten().next() { + Some(entry) => entry.path(), + None => bail!("Failed to extract archive from {}@{candidate}", opts.repo), + }; + + let store_dir = + github_store_path(ask_home, DEFAULT_GITHUB_HOST, owner, repo_name, candidate)?; + if let Some(lock) = acquire_entry_lock(&store_dir)? { + if let Some(parent) = store_dir.parent() { + std::fs::create_dir_all(parent)?; + } + cp_dir_atomic(&extracted_dir, &store_dir)?; + stamp_entry(&store_dir)?; + drop(lock); + } + + let files = if opts.skip_doc_extraction { + Vec::new() + } else { + extract_docs_from_dir( + &store_dir, + &opts.repo, + candidate, + opts.docs_path.as_deref(), + GITHUB_DOC_CANDIDATES, + )? + }; + return Ok(FetchResult { + files, + resolved_version: resolved_version.to_string(), + store_path: Some(store_dir), + store_subpath: opts.docs_path.clone(), + from_store_cache: false, + meta: FetchMeta { + ref_: Some(candidate.clone()), + ..Default::default() + }, + }); + } + + bail!( + "Failed to download tar.gz for {}@{reference} (tried: {}): {last_err}", + opts.repo, + candidates.join(", ") + ); +} + +fn unpack_tar_gz(bytes: &[u8], dest: &Path) -> Result<()> { + let decoder = GzDecoder::new(Cursor::new(bytes)); + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TOKEN: &str = "ghp_test_token"; + + // ── authenticated_clone_url (opensrc #52 + #66 parity) ────────── + + #[test] + fn auth_url_injects_on_exact_github_host() { + assert_eq!( + authenticated_clone_url("https://github.com/owner/repo.git", Some(TOKEN)), + format!("https://x-access-token:{TOKEN}@github.com/owner/repo.git") + ); + } + + #[test] + fn auth_url_case_insensitive_host() { + assert!( + authenticated_clone_url("https://GitHub.com/owner/repo.git", Some(TOKEN)) + .contains(&format!("x-access-token:{TOKEN}@")) + ); + } + + #[test] + fn auth_url_no_token_unchanged() { + assert_eq!( + authenticated_clone_url("https://github.com/owner/repo.git", None), + "https://github.com/owner/repo.git" + ); + assert_eq!( + authenticated_clone_url("https://github.com/owner/repo.git", Some("")), + "https://github.com/owner/repo.git" + ); + } + + #[test] + fn auth_url_rejects_host_confusion_and_schemes() { + for url in [ + "https://github.com.evil.com/owner/repo.git", + "https://evilgithub.com/owner/repo.git", + "https://gist.github.com/owner/repo.git", + "http://github.com/owner/repo.git", + "git://github.com/owner/repo.git", + "git@github.com:owner/repo.git", + "not a url", + ] { + assert_eq!(authenticated_clone_url(url, Some(TOKEN)), url, "{url}"); + } + } + + #[test] + fn redact_token_scrubs_all_occurrences() { + assert_eq!( + redact_token("a ghp_x b ghp_x", Some("ghp_x")), + "a *** b ***" + ); + assert_eq!( + redact_token("no token here", Some("ghp_x")), + "no token here" + ); + assert_eq!(redact_token("keep ghp_x", None), "keep ghp_x"); + } + + // ── ref_candidates ─────────────────────────────────────────────── + + #[test] + fn ref_candidates_base_chain() { + assert_eq!(ref_candidates("1.0.0", &[], &[]), vec!["1.0.0", "v1.0.0"]); + assert_eq!(ref_candidates("v1.0.0", &[], &[]), vec!["v1.0.0"]); + } + + #[test] + fn ref_candidates_dedup_extra_and_tail() { + let extra = vec!["ai@6.0.0".to_string()]; + let tail = vec!["master".to_string()]; + assert_eq!( + ref_candidates("main", &extra, &tail), + vec!["ai@6.0.0", "main", "vmain", "master"] + ); + // Duplicate across extra/base is dropped (first wins). + assert_eq!(ref_candidates("v1", &["v1".to_string()], &[]), vec!["v1"]); + } + + // ── end-to-end clone against a local git repo (offline) ───────── + + fn git(args: &[&str], cwd: &Path) { + let status = Command::new("git") + .args(args) + .current_dir(cwd) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "git {args:?} failed"); + } + + /// Build a local bare repo with tags v1.0.0, v2.0.0, and a bare 3.0.0, each + /// with a `docs/guide.md`. Returns the bare repo path (a valid clone remote). + fn create_local_remote(tmp: &Path) -> std::path::PathBuf { + let work = tmp.join("work"); + let bare = tmp.join("local-remote.git"); + std::fs::create_dir_all(&work).unwrap(); + git(&["init", "-b", "main", "."], &work); + git(&["config", "user.email", "t@t.com"], &work); + git(&["config", "user.name", "T"], &work); + git(&["config", "commit.gpgsign", "false"], &work); + git(&["config", "tag.gpgsign", "false"], &work); + std::fs::write(work.join("README.md"), "# Test Repo\n").unwrap(); + std::fs::create_dir_all(work.join("docs")).unwrap(); + + std::fs::write(work.join("docs/guide.md"), "# Guide v1\n").unwrap(); + git(&["add", "-A"], &work); + git(&["commit", "-m", "initial"], &work); + git(&["tag", "v1.0.0"], &work); + + std::fs::write(work.join("docs/guide.md"), "# Guide v2\n").unwrap(); + git(&["add", "-A"], &work); + git(&["commit", "-m", "update"], &work); + git(&["tag", "v2.0.0"], &work); + + std::fs::write(work.join("docs/guide.md"), "# Guide v3\n").unwrap(); + git(&["add", "-A"], &work); + git(&["commit", "-m", "bare-tag"], &work); + git(&["tag", "3.0.0"], &work); + + git( + &[ + "clone", + "--bare", + work.to_str().unwrap(), + bare.to_str().unwrap(), + ], + tmp, + ); + bare + } + + fn no_client() -> crate::http::mock::MockClient { + crate::http::mock::MockClient::new() + } + + #[test] + fn clone_materializes_v_prefixed_tag_into_nested_store() { + if !has_git() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let remote = create_local_remote(tmp.path()); + let ask_home = tmp.path().join("ask-home"); + let opts = GithubOptions { + name: "test-repo".into(), + version: "1.0.0".into(), + repo: "test/repo".into(), + tag: Some("v1.0.0".into()), + docs_path: Some("docs".into()), + remote_url: Some(remote.to_string_lossy().into_owned()), + ..Default::default() + }; + let result = fetch(&no_client(), &opts, &ask_home).unwrap(); + + let expected = ask_home.join("github/github.com/test/repo/v1.0.0"); + assert_eq!(result.store_path.as_deref(), Some(expected.as_path())); + assert_eq!(result.store_subpath.as_deref(), Some("docs")); + assert!(expected.join("README.md").exists()); + assert_eq!( + std::fs::read_to_string(expected.join("docs/guide.md")).unwrap(), + "# Guide v1\n" + ); + // .git stripped, commit captured, entry verifies. + assert!(!expected.join(".git").exists()); + assert!(RE_SHA40.is_match(result.meta.commit.as_deref().unwrap())); + assert!(verify_entry(&expected)); + assert_eq!(result.files.len(), 1); + } + + #[test] + fn clone_bare_tag_via_v_fallback_lands_under_requested_key() { + if !has_git() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let remote = create_local_remote(tmp.path()); + let ask_home = tmp.path().join("ask-home"); + // Request `3.0.0`; only a bare `3.0.0` tag exists (no `v3.0.0`), so the + // as-is candidate wins and the store key is `3.0.0`. + let opts = GithubOptions { + name: "r".into(), + version: "3.0.0".into(), + repo: "test/repo".into(), + tag: Some("3.0.0".into()), + docs_path: Some("docs".into()), + remote_url: Some(remote.to_string_lossy().into_owned()), + ..Default::default() + }; + let result = fetch(&no_client(), &opts, &ask_home).unwrap(); + assert!(result + .store_path + .as_deref() + .unwrap() + .ends_with("test/repo/3.0.0")); + assert_eq!( + std::fs::read_to_string(result.store_path.unwrap().join("docs/guide.md")).unwrap(), + "# Guide v3\n" + ); + } + + #[test] + fn second_fetch_hits_the_store_cache() { + if !has_git() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let remote = create_local_remote(tmp.path()); + let ask_home = tmp.path().join("ask-home"); + let opts = GithubOptions { + name: "r".into(), + version: "1.0.0".into(), + repo: "test/repo".into(), + tag: Some("v1.0.0".into()), + docs_path: Some("docs".into()), + remote_url: Some(remote.to_string_lossy().into_owned()), + ..Default::default() + }; + fetch(&no_client(), &opts, &ask_home).unwrap(); + // Second call: remote_url pointed at a now-removed path would fail a + // fresh clone, so a success proves the store-cache short-circuit fired. + std::fs::remove_dir_all(&remote).unwrap(); + let second = fetch(&no_client(), &opts, &ask_home).unwrap(); + assert!(second.from_store_cache); + assert_eq!(second.files.len(), 1); + } + + #[test] + fn invalid_repo_and_ref_rejected() { + let ask_home = tempfile::tempdir().unwrap(); + let bad_repo = GithubOptions { + repo: "not-a-repo".into(), + ..Default::default() + }; + assert!(fetch(&no_client(), &bad_repo, ask_home.path()).is_err()); + } +} diff --git a/crates/ask/src/sources/llms_txt.rs b/crates/ask/src/sources/llms_txt.rs new file mode 100644 index 0000000..793d0b5 --- /dev/null +++ b/crates/ask/src/sources/llms_txt.rs @@ -0,0 +1,137 @@ +//! llms-txt source — fetch a single `llms.txt`-style document and store it. +//! Rust port of `sources/llms-txt.ts`. + +use std::path::Path; + +use anyhow::{bail, Result}; + +use super::{to_entry_files, DocFile, FetchMeta, FetchResult}; +use crate::http::HttpClient; +use crate::store::{acquire_entry_lock, llms_txt_store_path, stamp_entry, write_entry_atomic}; + +/// Options for an llms-txt fetch (port of `LlmsTxtSourceOptions`). +#[derive(Debug, Clone)] +pub struct LlmsTxtOptions { + pub name: String, + pub version: String, + pub url: String, +} + +/// Fetch the document, derive a filename from the URL path (appending `.md` when +/// it isn't already `.md`/`.txt`), and materialize it into the store. +pub fn fetch( + client: &dyn HttpClient, + opts: &LlmsTxtOptions, + ask_home: &Path, +) -> Result { + let response = client.get(&opts.url)?; + if !response.ok() { + bail!("Failed to fetch {}: {}", opts.url, response.status); + } + let content = response.body; + if content.trim().is_empty() { + bail!("No content found at {}", opts.url); + } + + let filename = url_last_segment(&opts.url); + let file_path = if filename.ends_with(".md") || filename.ends_with(".txt") { + filename + } else { + format!("{filename}.md") + }; + let files = vec![DocFile { + path: file_path, + content, + }]; + + let store_dir = llms_txt_store_path(ask_home, &opts.url, &opts.version); + if !store_dir.exists() { + if let Some(lock) = acquire_entry_lock(&store_dir)? { + write_entry_atomic(&store_dir, &to_entry_files(&files))?; + stamp_entry(&store_dir)?; + drop(lock); + } + } + + Ok(FetchResult { + files, + resolved_version: opts.version.clone(), + store_path: Some(store_dir), + store_subpath: None, + from_store_cache: false, + meta: FetchMeta::default(), + }) +} + +/// The last non-empty path segment of `url`, or `llms.txt` (parity with TS +/// `urlObj.pathname.split('/').pop() || 'llms.txt'`). +fn url_last_segment(url: &str) -> String { + url::Url::parse(url) + .ok() + .and_then(|u| { + let mut segs = u.path_segments()?; + segs.rfind(|seg: &&str| !seg.is_empty()).map(str::to_string) + }) + .unwrap_or_else(|| "llms.txt".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + #[test] + fn fetches_and_stores_llms_txt() { + let dir = tempfile::tempdir().unwrap(); + let url = "https://example.com/llms.txt"; + let client = MockClient::new().with(url, 200, "# Docs\ncontent"); + let opts = LlmsTxtOptions { + name: "x".into(), + version: "1.0.0".into(), + url: url.into(), + }; + + let result = fetch(&client, &opts, dir.path()).unwrap(); + assert_eq!(result.files.len(), 1); + assert_eq!(result.files[0].path, "llms.txt"); + assert!(result.files[0].content.contains("content")); + let store = result.store_path.unwrap(); + assert!(store.join("llms.txt").exists()); + } + + #[test] + fn appends_md_when_no_doc_extension() { + let dir = tempfile::tempdir().unwrap(); + let url = "https://example.com/api/reference"; + let client = MockClient::new().with(url, 200, "body"); + let opts = LlmsTxtOptions { + name: "x".into(), + version: "1".into(), + url: url.into(), + }; + let result = fetch(&client, &opts, dir.path()).unwrap(); + assert_eq!(result.files[0].path, "reference.md"); + } + + #[test] + fn errors_on_non_ok_and_empty() { + let dir = tempfile::tempdir().unwrap(); + let url = "https://example.com/llms.txt"; + let miss = MockClient::new().with(url, 404, ""); + let opts = LlmsTxtOptions { + name: "x".into(), + version: "1".into(), + url: url.into(), + }; + assert!(fetch(&miss, &opts, dir.path()).is_err()); + + let empty = MockClient::new().with(url, 200, " \n "); + assert!(fetch(&empty, &opts, dir.path()).is_err()); + } + + #[test] + fn root_path_falls_back_to_llms_txt() { + assert_eq!(url_last_segment("https://x.io/"), "llms.txt"); + assert_eq!(url_last_segment("https://x.io/a/b/guide.md"), "guide.md"); + } +} diff --git a/crates/ask/src/sources/mod.rs b/crates/ask/src/sources/mod.rs new file mode 100644 index 0000000..71253ec --- /dev/null +++ b/crates/ask/src/sources/mod.rs @@ -0,0 +1,255 @@ +//! Source adapters — fetch docs from github / npm / llms-txt (web is deferred) +//! and materialize them into the global store. Rust port of +//! `packages/cli/src/sources/`. +//! +//! Shared here: the [`DocFile`]/[`FetchResult`] value types and the doc-file +//! collection helpers reused by the npm and github adapters. + +pub mod github; +pub mod llms_txt; +pub mod npm; + +use std::path::{Path, PathBuf}; + +use crate::store::EntryFile; + +/// A documentation file: a store-relative path (forward-slash separated) and its +/// text content. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DocFile { + pub path: String, + pub content: String, +} + +impl DocFile { + fn to_entry(&self) -> EntryFile { + EntryFile { + path: self.path.clone(), + content: self.content.clone(), + } + } +} + +/// Convert docs to store entry files. +pub fn to_entry_files(files: &[DocFile]) -> Vec { + files.iter().map(DocFile::to_entry).collect() +} + +/// Source-specific metadata propagated to the resolved cache / lockfile. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FetchMeta { + /// GitHub commit sha (40 hex chars). + pub commit: Option, + /// GitHub ref used (tag or branch name). + pub ref_: Option, + /// npm Subresource Integrity hash from `dist.integrity`. + pub integrity: Option, + /// npm tarball URL (omitted for local `node_modules` reads). + pub tarball: Option, + /// Absolute path to the local `node_modules/` dir when read locally. + pub install_path: Option, + /// web / llms-txt source URL(s). + pub urls: Option>, +} + +/// The result of a source fetch (port of `FetchResult`). +#[derive(Debug, Clone)] +pub struct FetchResult { + pub files: Vec, + pub resolved_version: String, + /// Absolute path to the finalized store entry. + pub store_path: Option, + /// Relative docs subpath inside `store_path` (github `docsPath`; empty + /// elsewhere). + pub store_subpath: Option, + /// True when served from an existing verified store entry without a fetch. + pub from_store_cache: bool, + pub meta: FetchMeta, +} + +/// Doc-file extensions collected from a docs directory. +const DOC_EXTS: &[&str] = &["md", "mdx", "txt", "rst"]; + +/// npm tarball docs-path auto-detect candidates (includes `dist/docs`). +pub const NPM_DOC_CANDIDATES: &[&str] = &[ + "docs", + "doc", + "dist/docs", + "documentation", + "guide", + "guides", +]; +/// github checkout docs-path auto-detect candidates. +pub const GITHUB_DOC_CANDIDATES: &[&str] = &["docs", "doc", "documentation", "guide", "guides"]; + +/// Whether `name` has a documentation extension (case-insensitive). +pub fn is_doc_file(name: &str) -> bool { + match name.rsplit_once('.') { + Some((_, ext)) => DOC_EXTS.contains(&ext.to_ascii_lowercase().as_str()), + None => false, + } +} + +/// Recursively collect doc files under `current`, returning paths relative to +/// `base` (forward-slash separated), sorted for determinism. +pub fn collect_doc_files(base: &Path, current: &Path) -> std::io::Result> { + let mut files = Vec::new(); + collect_into(base, current, &mut files)?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(files) +} + +fn collect_into(base: &Path, current: &Path, out: &mut Vec) -> std::io::Result<()> { + for entry in std::fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + collect_into(base, &path, out)?; + } else if is_doc_file(&entry.file_name().to_string_lossy()) { + let rel = path.strip_prefix(base).unwrap_or(&path); + let rel_str = rel + .components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + let content = std::fs::read_to_string(&path)?; + out.push(DocFile { + path: rel_str, + content, + }); + } + } + Ok(()) +} + +/// First candidate that exists as a directory under `dir`, or `None`. +pub fn detect_docs_path(dir: &Path, candidates: &[&str]) -> Option { + candidates + .iter() + .find(|c| dir.join(c).is_dir()) + .map(|c| c.to_string()) +} + +/// Resolve the docs directory (`docs_path` or auto-detected) inside `extracted`, +/// guard against traversal via realpath containment, and collect its doc files +/// (or a single file). Port of `extractDocsFromDir` / npm's equivalent. +pub fn extract_docs_from_dir( + extracted: &Path, + repo: &str, + ref_: &str, + docs_path: Option<&str>, + candidates: &[&str], +) -> anyhow::Result> { + let target = match docs_path.map(str::to_string).or_else(|| detect_docs_path(extracted, candidates)) { + Some(t) => t, + None => anyhow::bail!( + "No docs directory found in {repo}@{ref_}. Specify --path to point to the docs directory." + ), + }; + + let docs_dir = extracted.join(&target); + // Realpath containment: a symlink inside the checkout pointing outside must + // not let us read arbitrary files. + let resolved_base = std::fs::canonicalize(extracted)?; + let resolved_docs = if docs_dir.exists() { + std::fs::canonicalize(&docs_dir)? + } else { + docs_dir.clone() + }; + if !resolved_docs.starts_with(&resolved_base) { + anyhow::bail!("Unsafe docsPath '{target}': must be a relative path within the repository"); + } + if !docs_dir.exists() { + anyhow::bail!("Path \"{target}\" not found in {repo}@{ref_}"); + } + + if docs_dir.is_file() { + let content = std::fs::read_to_string(&docs_dir)?; + let name = docs_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + return Ok(vec![DocFile { + path: name, + content, + }]); + } + Ok(collect_doc_files(&docs_dir, &docs_dir)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_doc_file_matches_known_extensions() { + for ok in ["a.md", "b.MDX", "c.txt", "d.rst"] { + assert!(is_doc_file(ok), "{ok}"); + } + for no in ["a.png", "b.js", "noext", "c.mdxx"] { + assert!(!is_doc_file(no), "{no}"); + } + } + + #[test] + fn collect_and_extract_from_dir() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("docs/sub")).unwrap(); + std::fs::write(root.join("docs/a.md"), "A").unwrap(); + std::fs::write(root.join("docs/sub/b.mdx"), "B").unwrap(); + std::fs::write(root.join("docs/ignore.png"), "x").unwrap(); + + let files = + extract_docs_from_dir(root, "o/r", "v1", Some("docs"), GITHUB_DOC_CANDIDATES).unwrap(); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!(paths, vec!["a.md", "sub/b.mdx"]); + } + + #[test] + fn extract_auto_detects_docs_dir() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("doc")).unwrap(); + std::fs::write(dir.path().join("doc/x.md"), "X").unwrap(); + let files = + extract_docs_from_dir(dir.path(), "o/r", "v1", None, GITHUB_DOC_CANDIDATES).unwrap(); + assert_eq!(files.len(), 1); + } + + #[test] + fn extract_single_file_docs_path() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("README.md"), "hi").unwrap(); + let files = extract_docs_from_dir( + dir.path(), + "o/r", + "v1", + Some("README.md"), + GITHUB_DOC_CANDIDATES, + ) + .unwrap(); + assert_eq!( + files, + vec![DocFile { + path: "README.md".into(), + content: "hi".into() + }] + ); + } + + #[test] + fn extract_missing_docs_errors() { + let dir = tempfile::tempdir().unwrap(); + assert!( + extract_docs_from_dir(dir.path(), "o/r", "v1", None, GITHUB_DOC_CANDIDATES).is_err() + ); + assert!(extract_docs_from_dir( + dir.path(), + "o/r", + "v1", + Some("nope"), + GITHUB_DOC_CANDIDATES + ) + .is_err()); + } +} diff --git a/crates/ask/src/sources/npm.rs b/crates/ask/src/sources/npm.rs new file mode 100644 index 0000000..b4a0f0c --- /dev/null +++ b/crates/ask/src/sources/npm.rs @@ -0,0 +1,364 @@ +//! npm source — local-first read from `node_modules//`, falling +//! back to a published-tarball download. Rust port of `sources/npm.ts`. + +use std::io::Cursor; +use std::path::Path; +use std::process::Command; + +use anyhow::{bail, Result}; +use flate2::read::GzDecoder; + +use super::{ + collect_doc_files, detect_docs_path, to_entry_files, DocFile, FetchMeta, FetchResult, + NPM_DOC_CANDIDATES, +}; +use crate::http::HttpClient; +use crate::store::{ + acquire_entry_lock, lexical_clean, npm_store_path, stamp_entry, write_entry_atomic, +}; + +/// Options for an npm fetch (port of `NpmSourceOptions`). +#[derive(Debug, Clone, Default)] +pub struct NpmOptions { + pub name: String, + pub version: String, + pub package: Option, + pub docs_path: Option, +} + +/// Fetch docs for an npm package: try the installed `node_modules` copy first, +/// then a tarball download. +pub fn fetch( + client: &dyn HttpClient, + opts: &NpmOptions, + project_dir: &Path, + ask_home: &Path, +) -> Result { + let pkg = opts.package.as_deref().unwrap_or(&opts.name); + + if let Some(mut local) = + try_local_read(project_dir, pkg, &opts.version, opts.docs_path.as_deref()) + { + write_to_store(ask_home, pkg, &local.resolved_version, &local.files)?; + local.store_path = Some(npm_store_path(ask_home, pkg, &local.resolved_version)?); + return Ok(local); + } + + fetch_from_tarball(client, opts, pkg, ask_home) +} + +/// Match policy for the local-first read (node-semver semantics): +/// `latest` → any; a bare exact version → equality; a semver range → satisfies; +/// an opaque tag → equality. +fn version_matches(requested: &str, installed: &str) -> bool { + if requested == "latest" { + return true; + } + // A plain version string is an EXACT match in npm's range grammar (unlike the + // Cargo `semver` crate, where `1.2.3` means `^1.2.3`), so compare directly. + if semver::Version::parse(requested).is_ok() { + return requested == installed; + } + if let (Ok(req), Ok(v)) = ( + semver::VersionReq::parse(requested), + semver::Version::parse(installed), + ) { + return req.matches(&v); + } + requested == installed +} + +/// Read docs directly from `node_modules/` when the installed version +/// satisfies the request and the configured `docs_path` exists. `None` = not +/// viable (caller falls back to the tarball). +pub fn try_local_read( + project_dir: &Path, + pkg: &str, + requested_version: &str, + docs_path: Option<&str>, +) -> Option { + // No explicit docsPath: the local path exists only for the curated case. + let docs_path = docs_path?; + + let pkg_dir = project_dir.join("node_modules").join(pkg); + let pkg_json = pkg_dir.join("package.json"); + let meta_text = std::fs::read_to_string(&pkg_json).ok()?; + let installed_version = serde_json::from_str::(&meta_text) + .ok()? + .get("version")? + .as_str()? + .to_string(); + + if !version_matches(requested_version, &installed_version) { + return None; + } + + // String-level traversal guard (before touching the fs). + let docs_dir = pkg_dir.join(docs_path); + if !lexical_clean(&docs_dir).starts_with(lexical_clean(&pkg_dir)) { + return None; + } + if !docs_dir.exists() { + return None; + } + // Realpath guard: a symlink inside the package pointing outside is caught here. + let real_pkg = std::fs::canonicalize(&pkg_dir).ok()?; + let real_docs = std::fs::canonicalize(&docs_dir).ok()?; + if !real_docs.starts_with(&real_pkg) { + return None; + } + + let files = if docs_dir.is_file() { + let content = std::fs::read_to_string(&docs_dir).ok()?; + let name = docs_dir.file_name()?.to_string_lossy().into_owned(); + vec![DocFile { + path: name, + content, + }] + } else { + collect_doc_files(&docs_dir, &docs_dir).ok()? + }; + if files.is_empty() { + return None; + } + + Some(FetchResult { + files, + resolved_version: installed_version, + store_path: None, + store_subpath: None, + from_store_cache: false, + meta: FetchMeta { + install_path: Some(pkg_dir), + ..Default::default() + }, + }) +} + +fn npm_view(spec: &str, field: &str) -> Result { + let output = Command::new("npm").args(["view", spec, field]).output()?; + if !output.status.success() { + bail!( + "npm view {spec} {field} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn fetch_from_tarball( + client: &dyn HttpClient, + opts: &NpmOptions, + pkg: &str, + ask_home: &Path, +) -> Result { + let spec = format!("{pkg}@{}", opts.version); + let resolved_version = npm_view(&spec, "version")?; + let tarball_url = npm_view(&spec, "dist.tarball")?; + let integrity = npm_view(&spec, "dist.integrity") + .ok() + .filter(|s| !s.is_empty()); + + let response = client.get_bytes(&tarball_url, &[])?; + if !response.ok() { + bail!("Failed to download {tarball_url}: HTTP {}", response.status); + } + + let tmp = tempfile::Builder::new().prefix("ask-npm-").tempdir()?; + let decoder = GzDecoder::new(Cursor::new(&response.body)); + tar::Archive::new(decoder).unpack(tmp.path())?; + + let package_dir = tmp.path().join("package"); + let docs_path = opts + .docs_path + .clone() + .or_else(|| detect_docs_path(&package_dir, NPM_DOC_CANDIDATES)) + .ok_or_else(|| { + anyhow::anyhow!( + "No docs found in {spec}. Specify --docs-path to point to the docs directory \ + within the package." + ) + })?; + + let docs_dir = package_dir.join(&docs_path); + if !docs_dir.exists() { + bail!( + "Docs path \"{docs_path}\" not found in {spec}. Available paths:\n{}", + list_dirs(&package_dir) + ); + } + + let files = if docs_dir.is_file() { + let content = std::fs::read_to_string(&docs_dir)?; + let name = docs_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + vec![DocFile { + path: name, + content, + }] + } else { + collect_doc_files(&docs_dir, &docs_dir)? + }; + + write_to_store(ask_home, pkg, &resolved_version, &files)?; + + Ok(FetchResult { + files, + resolved_version: resolved_version.clone(), + store_path: Some(npm_store_path(ask_home, pkg, &resolved_version)?), + store_subpath: None, + from_store_cache: false, + meta: FetchMeta { + tarball: Some(tarball_url), + integrity, + ..Default::default() + }, + }) +} + +fn list_dirs(dir: &Path) -> String { + let Ok(entries) = std::fs::read_dir(dir) else { + return String::new(); + }; + let mut names: Vec = entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| format!(" - {}/", e.file_name().to_string_lossy())) + .collect(); + names.sort(); + names.join("\n") +} + +/// Write fetched docs into `/npm/@/` (skip if present). +fn write_to_store(ask_home: &Path, pkg: &str, version: &str, files: &[DocFile]) -> Result<()> { + let store_dir = npm_store_path(ask_home, pkg, version)?; + if store_dir.exists() { + return Ok(()); + } + if let Some(lock) = acquire_entry_lock(&store_dir)? { + write_entry_atomic(&store_dir, &to_entry_files(files))?; + stamp_entry(&store_dir)?; + drop(lock); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_matches_node_semver_semantics() { + assert!(version_matches("latest", "9.9.9")); + // Bare version → exact. + assert!(version_matches("1.2.3", "1.2.3")); + assert!(!version_matches("1.2.3", "1.2.4")); + // Range → satisfies. + assert!(version_matches("^1.2.0", "1.5.0")); + assert!(!version_matches("^1.2.0", "2.0.0")); + assert!(version_matches("~3.22.0", "3.22.9")); + // Opaque tag → exact. + assert!(version_matches("canary", "canary")); + assert!(!version_matches("canary", "1.0.0")); + } + + fn setup_pkg(project: &Path, pkg: &str, version: &str) -> std::path::PathBuf { + let pkg_dir = project.join("node_modules").join(pkg); + std::fs::create_dir_all(&pkg_dir).unwrap(); + std::fs::write( + pkg_dir.join("package.json"), + format!(r#"{{"name":"{pkg}","version":"{version}"}}"#), + ) + .unwrap(); + pkg_dir + } + + #[test] + fn local_read_returns_docs_when_version_matches() { + let dir = tempfile::tempdir().unwrap(); + let pkg_dir = setup_pkg(dir.path(), "acme", "2.0.0"); + std::fs::create_dir_all(pkg_dir.join("dist/docs")).unwrap(); + std::fs::write(pkg_dir.join("dist/docs/a.md"), "A").unwrap(); + + let result = try_local_read(dir.path(), "acme", "2.0.0", Some("dist/docs")).unwrap(); + assert_eq!(result.resolved_version, "2.0.0"); + assert_eq!( + result.files, + vec![DocFile { + path: "a.md".into(), + content: "A".into() + }] + ); + assert_eq!(result.meta.install_path.as_deref(), Some(pkg_dir.as_path())); + } + + #[test] + fn local_read_none_without_docs_path() { + let dir = tempfile::tempdir().unwrap(); + setup_pkg(dir.path(), "acme", "2.0.0"); + assert!(try_local_read(dir.path(), "acme", "2.0.0", None).is_none()); + } + + #[test] + fn local_read_none_on_version_mismatch() { + let dir = tempfile::tempdir().unwrap(); + let pkg_dir = setup_pkg(dir.path(), "acme", "1.0.0"); + std::fs::create_dir_all(pkg_dir.join("docs")).unwrap(); + std::fs::write(pkg_dir.join("docs/a.md"), "A").unwrap(); + assert!(try_local_read(dir.path(), "acme", "2.0.0", Some("docs")).is_none()); + } + + #[test] + fn local_read_none_on_traversal_docs_path() { + let dir = tempfile::tempdir().unwrap(); + setup_pkg(dir.path(), "acme", "2.0.0"); + assert!(try_local_read(dir.path(), "acme", "2.0.0", Some("../../etc")).is_none()); + } + + #[test] + fn local_read_none_when_empty_docs_dir() { + let dir = tempfile::tempdir().unwrap(); + let pkg_dir = setup_pkg(dir.path(), "acme", "2.0.0"); + std::fs::create_dir_all(pkg_dir.join("docs")).unwrap(); + // No doc files inside → treated as a miss. + assert!(try_local_read(dir.path(), "acme", "2.0.0", Some("docs")).is_none()); + } + + #[test] + fn local_read_single_file_docs_path() { + let dir = tempfile::tempdir().unwrap(); + let pkg_dir = setup_pkg(dir.path(), "acme", "2.0.0"); + std::fs::write(pkg_dir.join("README.md"), "readme").unwrap(); + let result = try_local_read(dir.path(), "acme", "2.0.0", Some("README.md")).unwrap(); + assert_eq!( + result.files, + vec![DocFile { + path: "README.md".into(), + content: "readme".into() + }] + ); + } + + #[test] + fn fetch_writes_local_read_into_store() { + let dir = tempfile::tempdir().unwrap(); + let ask_home = dir.path().join("ask-home"); + let pkg_dir = setup_pkg(dir.path(), "acme", "2.0.0"); + std::fs::create_dir_all(pkg_dir.join("docs")).unwrap(); + std::fs::write(pkg_dir.join("docs/a.md"), "A").unwrap(); + + let client = crate::http::mock::MockClient::new(); + let opts = NpmOptions { + name: "acme".into(), + version: "2.0.0".into(), + docs_path: Some("docs".into()), + ..Default::default() + }; + let result = fetch(&client, &opts, dir.path(), &ask_home).unwrap(); + let store = result.store_path.unwrap(); + assert!(store.ends_with("npm/acme@2.0.0")); + assert!(store.join("a.md").exists()); + } +} From 77a844c4a16ea03887687096234f2cfedc12ce72 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:50:19 +0900 Subject: [PATCH 20/44] feat(rust): port storage (save_docs materialization + list_docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports storage.ts as storage.rs. - save_docs: copy (write files + INDEX.md), link (symlink project→store docs subdir, PermissionDenied → copy fallback), ref (no local materialization, returns effective_store_path). remove_docs by version or all. - list_docs: joins ask.json ⨝ resolved.json — declared-but-uninstalled entries surface as version "unresolved"; in-place + intent-skills branches handled (intent skill enumeration deferred with agents-intent). Sorted by name. Reuses ask_json::StoreMode (added Default=Copy). 7 new tests, 161 total green. --- crates/ask/src/ask_json.rs | 3 +- crates/ask/src/lib.rs | 1 + crates/ask/src/storage.rs | 443 +++++++++++++++++++++++++++++++++++++ 3 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 crates/ask/src/storage.rs diff --git a/crates/ask/src/ask_json.rs b/crates/ask/src/ask_json.rs index 2e2fdb6..47e10a6 100644 --- a/crates/ask/src/ask_json.rs +++ b/crates/ask/src/ask_json.rs @@ -42,9 +42,10 @@ pub struct AskJson { } /// Materialization mode for the eager `--fetch` path. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum StoreMode { + #[default] Copy, Link, Ref, diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index dd061de..8c3d879 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -17,6 +17,7 @@ pub mod resolved; pub mod resolvers; pub mod sources; pub mod spec; +pub mod storage; pub mod store; use std::fmt; diff --git a/crates/ask/src/storage.rs b/crates/ask/src/storage.rs new file mode 100644 index 0000000..96f3fa0 --- /dev/null +++ b/crates/ask/src/storage.rs @@ -0,0 +1,443 @@ +//! Project-local docs materialization + the `ask list` view. Rust port of +//! `packages/cli/src/storage.ts`. +//! +//! `save_docs` writes (or links/refs) the fetched docs under +//! `.ask/docs/@/`; `list_docs` joins `ask.json` (intent) with +//! `.ask/resolved.json` (last materialization) for `ask list`. + +use std::path::{Path, PathBuf}; + +pub use crate::ask_json::StoreMode; +use crate::io::{get_ask_dir, read_ask_json, read_resolved_json}; +use crate::resolved::{EntryFormat, Materialization}; +use crate::sources::DocFile; +use crate::spec::library_name_from_spec; + +/// Options for [`save_docs`]. +#[derive(Debug, Clone, Default)] +pub struct SaveDocsOptions { + pub store_mode: StoreMode, + pub store_path: Option, + /// Relative docs subpath inside `store_path` (github `docsPath`). Link/ref + /// modes join this so the symlink/ref target is the docs dir, not the repo + /// root. + pub store_subpath: Option, +} + +pub fn get_docs_dir(project_dir: &Path) -> PathBuf { + get_ask_dir(project_dir).join("docs") +} + +pub fn get_library_docs_dir(project_dir: &Path, name: &str, version: &str) -> PathBuf { + get_docs_dir(project_dir).join(format!("{name}@{version}")) +} + +fn effective_store_path(store_path: &Path, store_subpath: Option<&str>) -> PathBuf { + match store_subpath.filter(|s| !s.is_empty()) { + Some(sub) => store_path.join(sub), + None => store_path.to_path_buf(), + } +} + +/// Materialize docs into the project. Returns the path AGENTS.md should point at. +pub fn save_docs( + project_dir: &Path, + name: &str, + version: &str, + files: &[DocFile], + options: &SaveDocsOptions, +) -> anyhow::Result { + let docs_dir = get_library_docs_dir(project_dir, name, version); + + // ref mode: no project-local materialization. + if options.store_mode == StoreMode::Ref { + return Ok(match &options.store_path { + Some(sp) => effective_store_path(sp, options.store_subpath.as_deref()), + None => docs_dir, + }); + } + + // link mode: symlink project-local → store (docs subdir). + if options.store_mode == StoreMode::Link { + if let Some(sp) = &options.store_path { + let link_target = effective_store_path(sp, options.store_subpath.as_deref()); + if docs_dir.exists() { + std::fs::remove_dir_all(&docs_dir)?; + } + if let Some(parent) = docs_dir.parent() { + std::fs::create_dir_all(parent)?; + } + match symlink_dir(&link_target, &docs_dir) { + Ok(()) => return Ok(docs_dir), + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + // Clean up partial state, then fall through to copy. + let _ = std::fs::remove_dir_all(&docs_dir); + eprintln!( + " Symlink creation failed ({}), falling back to copy mode", + e.kind() + ); + } + Err(e) => { + let _ = std::fs::remove_dir_all(&docs_dir); + anyhow::bail!("Failed to create symlink at {}: {e}", docs_dir.display()); + } + } + } + } + + // copy mode (default). + if docs_dir.exists() { + std::fs::remove_dir_all(&docs_dir)?; + } + std::fs::create_dir_all(&docs_dir)?; + for file in files { + let file_path = docs_dir.join(&file.path); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&file_path, &file.content)?; + } + let index = files + .iter() + .map(|f| format!("- [{}](./{})", f.path, f.path)) + .collect::>() + .join("\n"); + std::fs::write( + docs_dir.join("INDEX.md"), + format!("# {name}@{version} Documentation\n\n{index}\n"), + )?; + + Ok(docs_dir) +} + +#[cfg(unix)] +fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) +} + +#[cfg(not(unix))] +fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_dir(target, link) +} + +/// Remove project-local docs for `name` (all versions when `version` is `None`). +pub fn remove_docs(project_dir: &Path, name: &str, version: Option<&str>) -> anyhow::Result<()> { + if let Some(version) = version { + let docs_dir = get_library_docs_dir(project_dir, name, version); + if docs_dir.exists() { + std::fs::remove_dir_all(&docs_dir)?; + } + return Ok(()); + } + let base = get_docs_dir(project_dir); + if !base.exists() { + return Ok(()); + } + let prefix = format!("{name}@"); + for entry in std::fs::read_dir(&base)? { + let entry = entry?; + if entry.file_name().to_string_lossy().starts_with(&prefix) { + std::fs::remove_dir_all(entry.path())?; + } + } + Ok(()) +} + +/// Source classification for a list entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListSource { + PmDriven, + Github, + Unresolved, +} + +/// One `ask list` row, joined from `ask.json` + `.ask/resolved.json`. Entries +/// declared but never installed surface as `version: "unresolved"`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListDocsEntry { + pub name: String, + pub version: String, + pub format: EntryFormat, + pub source: ListSource, + pub spec: String, + pub location: String, + pub file_count: u64, +} + +pub fn list_docs(project_dir: &Path) -> Vec { + let Ok(Some(ask_json)) = read_ask_json(project_dir) else { + return Vec::new(); + }; + let resolved = read_resolved_json(project_dir); + + let mut out = Vec::new(); + for entry in &ask_json.libraries { + let spec = entry.spec().to_string(); + let name = library_name_from_spec(&spec); + let source_kind = if spec.starts_with("github:") { + ListSource::Github + } else { + ListSource::PmDriven + }; + + let Some(cached) = resolved.entries.get(&name) else { + out.push(ListDocsEntry { + name, + version: "unresolved".into(), + format: EntryFormat::Docs, + source: ListSource::Unresolved, + spec, + location: "(not installed — run `ask install`)".into(), + file_count: 0, + }); + continue; + }; + + // intent-skills format: docs live in node_modules; the skill listing is + // sourced from the AGENTS.md intent block (agents-intent, deferred). + if cached.format == Some(EntryFormat::IntentSkills) { + out.push(ListDocsEntry { + name, + version: cached.resolved_version.clone(), + format: EntryFormat::IntentSkills, + source: source_kind, + spec: spec.clone(), + location: format!("node_modules/{}", pkg_from_spec(&spec)), + file_count: 0, + }); + continue; + } + + if cached.materialization == Some(Materialization::InPlace) { + if let Some(in_place) = &cached.in_place_path { + out.push(ListDocsEntry { + name, + version: cached.resolved_version.clone(), + format: EntryFormat::Docs, + source: source_kind, + spec, + location: in_place.clone(), + file_count: cached.file_count, + }); + continue; + } + } + + let docs_dir = get_library_docs_dir(project_dir, &name, &cached.resolved_version); + let file_count = if docs_dir.exists() { + count_files(&docs_dir) + } else { + cached.file_count + }; + let location = pathdiff_relative(project_dir, &docs_dir) + .unwrap_or_else(|| docs_dir.to_string_lossy().into_owned()); + out.push(ListDocsEntry { + name, + version: cached.resolved_version.clone(), + format: EntryFormat::Docs, + source: source_kind, + spec, + location, + file_count, + }); + } + out.sort_by(|a, b| a.name.cmp(&b.name)); + out +} + +fn pkg_from_spec(spec: &str) -> &str { + match spec.find(':') { + Some(i) => &spec[i + 1..], + None => spec, + } +} + +/// Path of `target` relative to `base` (forward-slash), or `None` when `target` +/// is not under `base`. +fn pathdiff_relative(base: &Path, target: &Path) -> Option { + let rel = target.strip_prefix(base).ok()?; + if rel.as_os_str().is_empty() { + return None; + } + Some( + rel.components() + .map(|c| c.as_os_str().to_string_lossy()) + .collect::>() + .join("/"), + ) +} + +fn count_files(dir: &Path) -> u64 { + let mut count = 0; + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + for entry in entries.flatten() { + match entry.file_type() { + Ok(t) if t.is_dir() => count += count_files(&entry.path()), + Ok(_) => count += 1, + Err(_) => {} + } + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + fn df(path: &str, content: &str) -> DocFile { + DocFile { + path: path.into(), + content: content.into(), + } + } + + #[test] + fn save_copy_writes_files_and_index() { + let dir = tempfile::tempdir().unwrap(); + let out = save_docs( + dir.path(), + "acme", + "1.0.0", + &[df("a.md", "A"), df("sub/b.md", "B")], + &SaveDocsOptions::default(), + ) + .unwrap(); + assert!(out.ends_with(".ask/docs/acme@1.0.0")); + assert_eq!(std::fs::read_to_string(out.join("a.md")).unwrap(), "A"); + assert_eq!(std::fs::read_to_string(out.join("sub/b.md")).unwrap(), "B"); + let index = std::fs::read_to_string(out.join("INDEX.md")).unwrap(); + assert!(index.contains("# acme@1.0.0 Documentation")); + assert!(index.contains("- [a.md](./a.md)")); + } + + #[test] + fn save_copy_replaces_existing() { + let dir = tempfile::tempdir().unwrap(); + save_docs( + dir.path(), + "acme", + "1.0.0", + &[df("old.md", "x")], + &SaveDocsOptions::default(), + ) + .unwrap(); + let out = save_docs( + dir.path(), + "acme", + "1.0.0", + &[df("new.md", "y")], + &SaveDocsOptions::default(), + ) + .unwrap(); + assert!(!out.join("old.md").exists()); + assert!(out.join("new.md").exists()); + } + + #[test] + fn ref_mode_returns_effective_store_path() { + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().join("store/github.com/o/r/v1"); + let out = save_docs( + dir.path(), + "acme", + "1.0.0", + &[], + &SaveDocsOptions { + store_mode: StoreMode::Ref, + store_path: Some(store.clone()), + store_subpath: Some("docs".into()), + }, + ) + .unwrap(); + assert_eq!(out, store.join("docs")); + // Nothing was materialized locally. + assert!(!get_library_docs_dir(dir.path(), "acme", "1.0.0").exists()); + } + + #[cfg(unix)] + #[test] + fn link_mode_creates_symlink_to_subpath() { + let dir = tempfile::tempdir().unwrap(); + let store = dir.path().join("store"); + std::fs::create_dir_all(store.join("docs")).unwrap(); + std::fs::write(store.join("docs/guide.md"), "G").unwrap(); + let out = save_docs( + dir.path(), + "acme", + "1.0.0", + &[], + &SaveDocsOptions { + store_mode: StoreMode::Link, + store_path: Some(store.clone()), + store_subpath: Some("docs".into()), + }, + ) + .unwrap(); + assert!(out.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read_to_string(out.join("guide.md")).unwrap(), "G"); + } + + #[test] + fn remove_docs_by_version_and_all() { + let dir = tempfile::tempdir().unwrap(); + save_docs( + dir.path(), + "acme", + "1.0.0", + &[df("a.md", "A")], + &SaveDocsOptions::default(), + ) + .unwrap(); + save_docs( + dir.path(), + "acme", + "2.0.0", + &[df("a.md", "A")], + &SaveDocsOptions::default(), + ) + .unwrap(); + remove_docs(dir.path(), "acme", Some("1.0.0")).unwrap(); + assert!(!get_library_docs_dir(dir.path(), "acme", "1.0.0").exists()); + assert!(get_library_docs_dir(dir.path(), "acme", "2.0.0").exists()); + remove_docs(dir.path(), "acme", None).unwrap(); + assert!(!get_library_docs_dir(dir.path(), "acme", "2.0.0").exists()); + } + + #[test] + fn list_docs_empty_without_ask_json() { + let dir = tempfile::tempdir().unwrap(); + assert!(list_docs(dir.path()).is_empty()); + } + + #[test] + fn list_docs_marks_unresolved_and_resolved() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(get_ask_dir(dir.path())).unwrap(); + std::fs::write( + crate::io::get_ask_json_path(dir.path()), + r#"{"libraries":["npm:acme","github:o/other"]}"#, + ) + .unwrap(); + // Only acme is resolved. + std::fs::write( + crate::io::get_resolved_json_path(dir.path()), + r#"{"schemaVersion":1,"generatedAt":"2026-01-01T00:00:00Z","entries":{ + "acme":{"spec":"npm:acme","resolvedVersion":"1.2.3", + "contentHash":"sha256-0000000000000000000000000000000000000000000000000000000000000000", + "fetchedAt":"2026-01-01T00:00:00Z","fileCount":3}}}"#, + ) + .unwrap(); + + let entries = list_docs(dir.path()); + assert_eq!(entries.len(), 2); + // Sorted by name: "acme" before "other". + assert_eq!(entries[0].name, "acme"); + assert_eq!(entries[0].version, "1.2.3"); + assert_eq!(entries[0].source, ListSource::PmDriven); + assert_eq!(entries[1].name, "other"); + assert_eq!(entries[1].version, "unresolved"); + assert_eq!(entries[1].source, ListSource::Unresolved); + } +} From f88b486535e3dd4ce1106d71a431580197967f7a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:52:07 +0900 Subject: [PATCH 21/44] feat(rust): port skill + AGENTS.md generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports skill.ts and agents.ts. - skill.rs: generate_skill writes .claude/skills/-docs/SKILL.md (lazy-first template referencing ask src/docs/search, major-version pin hint). get_skill_dir + remove_skill. - agents.rs: generate_agents_md maintains the block — replace-in-place when both markers present, append when absent, strip on empty libraries (removing the file if the block was all), warn + leave untouched on an unmatched marker. Ensures CLAUDE.md carries a @AGENTS.md reference (no dupe). 15 new tests, 169 total green. --- crates/ask/src/agents.rs | 234 +++++++++++++++++++++++++++++++++++++++ crates/ask/src/lib.rs | 2 + crates/ask/src/skill.rs | 111 +++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 crates/ask/src/agents.rs create mode 100644 crates/ask/src/skill.rs diff --git a/crates/ask/src/agents.rs b/crates/ask/src/agents.rs new file mode 100644 index 0000000..beb3965 --- /dev/null +++ b/crates/ask/src/agents.rs @@ -0,0 +1,234 @@ +//! `AGENTS.md` generation — maintains the `` +//! block and a `@AGENTS.md` reference in `CLAUDE.md`. Rust port of `agents.ts`. + +use std::path::Path; + +const BEGIN_MARKER: &str = ""; +const END_MARKER: &str = ""; + +/// A library to render into the AGENTS.md docs block. +#[derive(Debug, Clone)] +pub struct LazyLibraryInfo { + pub name: String, + pub version: String, + pub spec: String, +} + +/// Regenerate the ask-docs block in `AGENTS.md`. With no libraries, strips a +/// previously generated block (removing the file if it becomes empty). Returns +/// the AGENTS.md path (empty string when the file was left absent/untouched). +pub fn generate_agents_md(project_dir: &Path, libraries: &[LazyLibraryInfo]) -> String { + let agents_path = project_dir.join("AGENTS.md"); + + if libraries.is_empty() { + strip_block(project_dir, &agents_path); + return String::new(); + } + + let sections: Vec = libraries + .iter() + .map(|lib| { + let major = lib.version.split('.').next().unwrap_or(&lib.version); + let name = &lib.name; + let version = &lib.version; + format!( + "## {name} v{version}\n\n\ + > **WARNING:** This version may differ from your training data.\n\ + > Use `ask docs {name}` to read the documentation before writing any {name}-related code.\n\ + > Heed deprecation notices and breaking changes.\n\n\ + - **Version**: `{version}` — use `\"^{major}\"` in package.json (NOT older major versions)\n\ + - **Docs**: `ask docs {name}` — prints documentation paths (lazy fetch on first use)\n\ + - **Source**: `ask src {name}` — prints the cached source tree path" + ) + }) + .collect(); + + let generated_block = format!( + "{BEGIN_MARKER}\n\ + # Documentation References\n\n\ + The libraries in this project may have APIs and patterns that differ from your training data.\n\ + **Always read the relevant documentation before writing code.**\n\n\ + ## Accessing Library Documentation\n\n\ + Use the lazy commands `ask src ` and `ask docs ` to access\n\ + documentation on-demand. They print absolute paths to the cached source tree\n\ + (and any `*doc*` directories), with automatic fetch on cache miss:\n\n\ + ```bash\n\ + rg \"pattern\" $(ask src )\n\ + cat \"$(ask src )/README.md\"\n\ + fd \"\\.md$\" \"$(ask docs | head -n 1)\"\n\ + ```\n\n\ + {}\n\ + {END_MARKER}", + sections.join("\n\n") + ); + + if let Ok(existing) = std::fs::read_to_string(&agents_path) { + match (existing.find(BEGIN_MARKER), existing.find(END_MARKER)) { + (Some(begin), Some(end)) => { + let updated = format!( + "{}{generated_block}{}", + &existing[..begin], + &existing[end + END_MARKER.len()..] + ); + let _ = std::fs::write(&agents_path, updated); + } + _ => { + let _ = std::fs::write( + &agents_path, + format!("{}\n\n{generated_block}\n", existing.trim_end()), + ); + } + } + } else { + let _ = std::fs::write(&agents_path, format!("{generated_block}\n")); + } + + update_claude_md(project_dir); + agents_path.to_string_lossy().into_owned() +} + +/// Strip a previously generated block for the empty-libraries case. +fn strip_block(project_dir: &Path, agents_path: &Path) { + let Ok(existing) = std::fs::read_to_string(agents_path) else { + return; + }; + match (existing.find(BEGIN_MARKER), existing.find(END_MARKER)) { + (Some(begin), Some(end)) => { + let head = existing[..begin].trim_end_matches('\n'); + let tail = existing[end + END_MARKER.len()..].trim_start_matches('\n'); + let stripped = if head.is_empty() { + tail.to_string() + } else if tail.is_empty() { + format!("{head}\n") + } else { + format!("{head}\n\n{tail}") + }; + if stripped.is_empty() { + let _ = std::fs::remove_file(agents_path); + } else { + let _ = std::fs::write(agents_path, stripped); + } + } + (Some(_), None) | (None, Some(_)) => { + // Unmatched marker — likely hand-edited/truncated. Leave it for the + // user to repair rather than emitting further corruption. + let rel = agents_path.strip_prefix(project_dir).unwrap_or(agents_path); + eprintln!( + " Warning: {} has an unmatched ask-docs marker — leaving file untouched. Inspect \ + manually to restore the BEGIN/END pair.", + rel.display() + ); + } + (None, None) => {} + } +} + +/// Ensure `CLAUDE.md` references `@AGENTS.md`. +fn update_claude_md(project_dir: &Path) { + let claude_path = project_dir.join("CLAUDE.md"); + let claude_ref = "@AGENTS.md"; + match std::fs::read_to_string(&claude_path) { + Ok(content) if content.contains(claude_ref) => {} + Ok(content) => { + let _ = std::fs::write( + &claude_path, + format!("{}\n{claude_ref}\n", content.trim_end()), + ); + } + Err(_) => { + let _ = std::fs::write(&claude_path, format!("{claude_ref}\n")); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lib(name: &str, version: &str) -> LazyLibraryInfo { + LazyLibraryInfo { + name: name.into(), + version: version.into(), + spec: format!("npm:{name}"), + } + } + + #[test] + fn generates_block_and_claude_ref() { + let dir = tempfile::tempdir().unwrap(); + let path = generate_agents_md(dir.path(), &[lib("next", "15.0.3")]); + assert!(!path.is_empty()); + let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert!(content.starts_with(BEGIN_MARKER)); + assert!(content.contains("## next v15.0.3")); + assert!(content.contains(r#"use `"^15"`"#)); + assert!(content.trim_end().ends_with(END_MARKER)); + // CLAUDE.md gets the reference. + assert_eq!( + std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(), + "@AGENTS.md\n" + ); + } + + #[test] + fn replaces_existing_block_preserving_surrounding_text() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + format!("# My Project\n\nIntro.\n\n{BEGIN_MARKER}\nold\n{END_MARKER}\n\n## Footer\n"), + ) + .unwrap(); + generate_agents_md(dir.path(), &[lib("vue", "3.4.0")]); + let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert!(content.starts_with("# My Project")); + assert!(content.contains("## Footer")); + assert!(content.contains("## vue v3.4.0")); + assert!(!content.contains("\nold\n")); + } + + #[test] + fn appends_block_when_no_markers_present() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("AGENTS.md"), "# Existing\n").unwrap(); + generate_agents_md(dir.path(), &[lib("next", "1.0.0")]); + let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert!(content.starts_with("# Existing")); + assert!(content.contains(BEGIN_MARKER)); + } + + #[test] + fn empty_libraries_strips_block() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + format!("# Head\n\n{BEGIN_MARKER}\nx\n{END_MARKER}\n\n## Tail\n"), + ) + .unwrap(); + let result = generate_agents_md(dir.path(), &[]); + assert_eq!(result, ""); + let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert_eq!(content, "# Head\n\n## Tail\n"); + assert!(!content.contains(BEGIN_MARKER)); + } + + #[test] + fn empty_libraries_removes_file_when_block_was_all() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + format!("{BEGIN_MARKER}\nx\n{END_MARKER}"), + ) + .unwrap(); + generate_agents_md(dir.path(), &[]); + assert!(!dir.path().join("AGENTS.md").exists()); + } + + #[test] + fn does_not_duplicate_claude_ref() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("CLAUDE.md"), "# Rules\n@AGENTS.md\n").unwrap(); + generate_agents_md(dir.path(), &[lib("next", "1.0.0")]); + let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(); + assert_eq!(content.matches("@AGENTS.md").count(), 1); + } +} diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 8c3d879..60dc8aa 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -6,6 +6,7 @@ //! the `rust-port-20260704` track). Until a command is ported it reports a //! "not yet ported" notice via [`NotPorted`]. +pub mod agents; pub mod ask_json; pub mod cli; pub mod http; @@ -15,6 +16,7 @@ pub mod markers; pub mod registry; pub mod resolved; pub mod resolvers; +pub mod skill; pub mod sources; pub mod spec; pub mod storage; diff --git a/crates/ask/src/skill.rs b/crates/ask/src/skill.rs new file mode 100644 index 0000000..029520a --- /dev/null +++ b/crates/ask/src/skill.rs @@ -0,0 +1,111 @@ +//! Claude Code skill generation — writes a lazy-first `SKILL.md` per library. +//! Rust port of `packages/cli/src/skill.ts`. + +use std::path::{Path, PathBuf}; + +/// `.claude/skills/-docs/` for a library slug. +pub fn get_skill_dir(project_dir: &Path, name: &str) -> PathBuf { + project_dir + .join(".claude") + .join("skills") + .join(format!("{name}-docs")) +} + +/// Write a lazy-first `SKILL.md` that references `ask src`/`ask docs` for +/// on-demand documentation, and return its path. +pub fn generate_skill(project_dir: &Path, name: &str, version: &str) -> anyhow::Result { + let skill_dir = get_skill_dir(project_dir, name); + std::fs::create_dir_all(&skill_dir)?; + + let major = version.split('.').next().unwrap_or(version); + let content = format!( + r#"--- +name: {name}-docs +description: {name} v{version} documentation reference. TRIGGER when writing or modifying code that imports or uses {name}. +--- + +# {name} v{version} Documentation + +This project uses **{name} v{version}**. +The APIs and patterns may differ from your training data. +**Read the relevant docs before writing any code.** + +## Version +- Current: `{version}` +- In package.json, use `"^{major}"` (NOT older major versions) + +## Quick Access + +```bash +# Get the cached source tree path (lazy fetch on first use) +ask src {name} + +# Get all candidate documentation paths +ask docs {name} + +# Search across the library source +rg "pattern" $(ask src {name}) + +# Semantic search over the pinned source (token-efficient; needs csp — optional) +# For "how does X work internally" questions, prefer this over reading whole files: +ask search {name} "how does work" + +# Read a specific doc file +cat "$(ask src {name})/README.md" + +# Find all markdown files in doc directories +fd "\.md$" $(ask docs {name}) +``` + +## Instructions +1. Before writing any {name}-related code, run `ask docs {name}` and read the relevant guides +2. Heed deprecation notices and breaking changes +3. Prefer patterns shown in the documentation over patterns from training data +4. When adding {name} to package.json, use version `"^{major}"` +"# + ); + + let skill_path = skill_dir.join("SKILL.md"); + std::fs::write(&skill_path, content)?; + Ok(skill_path) +} + +/// Remove `.claude/skills/-docs/`. +pub fn remove_skill(project_dir: &Path, name: &str) -> anyhow::Result<()> { + let skill_dir = get_skill_dir(project_dir, name); + if skill_dir.exists() { + std::fs::remove_dir_all(&skill_dir)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_skill_with_version_frontmatter() { + let dir = tempfile::tempdir().unwrap(); + let path = generate_skill(dir.path(), "next", "15.0.3").unwrap(); + assert!(path.ends_with(".claude/skills/next-docs/SKILL.md")); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("name: next-docs")); + assert!(content.contains("next v15.0.3 documentation reference")); + // Major-version hint. + assert!(content.contains(r#"use `"^15"`"#)); + // Lazy commands referenced. + assert!(content.contains("ask src next")); + assert!(content.contains("ask docs next")); + // Literal regex preserved. + assert!(content.contains(r#"fd "\.md$""#)); + } + + #[test] + fn remove_skill_deletes_dir() { + let dir = tempfile::tempdir().unwrap(); + generate_skill(dir.path(), "next", "1.0.0").unwrap(); + assert!(get_skill_dir(dir.path(), "next").exists()); + remove_skill(dir.path(), "next").unwrap(); + assert!(!get_skill_dir(dir.path(), "next").exists()); + } +} From d2e65b07ba9833944ff8999033065a58bc9b0a4a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:55:28 +0900 Subject: [PATCH 22/44] feat(rust): port install orchestrator + ignore-file management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Rust port. Ports install.ts (run_install) and ignore-files.ts. - install.rs: lazy-first run_install — reads ask.json, resolves each library's version (github from the spec string with leading-v strip; npm from the lockfile chain), generates SKILL.md per library + a combined AGENTS.md, and runs manage_ignore_files. Creates an empty ask.json when missing; strips the AGENTS.md block + ignore markers on a full install with no libraries. No docs downloaded (agents fetch lazily). RunInstallOptions.only_specs for scoped installs. - ignore_files.rs: write/remove nested configs inside .ask/docs/ (byte-exact .gitattributes/eslint/biome/markdownlint templates) + patch/unpatch root ignore files (.prettierignore/.gitignore/sonar/markdownlint) via the Hash marker block. Opt-in gate (ask.json OR .ask/skills[-lock]). 9 new tests, 178 total green. --- crates/ask/src/ignore_files.rs | 272 +++++++++++++++++++++++++++++++++ crates/ask/src/install.rs | 235 ++++++++++++++++++++++++++++ crates/ask/src/lib.rs | 2 + 3 files changed, 509 insertions(+) create mode 100644 crates/ask/src/ignore_files.rs create mode 100644 crates/ask/src/install.rs diff --git a/crates/ask/src/ignore_files.rs b/crates/ask/src/ignore_files.rs new file mode 100644 index 0000000..66dc6ee --- /dev/null +++ b/crates/ask/src/ignore_files.rs @@ -0,0 +1,272 @@ +//! Vendored-docs ignore-file management. Rust port of `ignore-files.ts`. +//! +//! `.ask/docs/` holds third-party docs — lint/format/review tools should skip it +//! while agents still read it. Strategy: (A) write self-contained nested configs +//! inside `.ask/docs/` for tools with hierarchical config, (C) patch root files +//! for tools without nested config (Prettier, SonarQube, legacy markdownlint). + +use std::path::Path; + +use crate::io::read_ask_json; +use crate::markers::{inject, remove as remove_marker, wrap, MarkerSyntax}; +use crate::storage::get_docs_dir; + +/// install (create/patch) vs remove (delete/strip). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IgnoreMode { + Install, + Remove, +} + +struct NestedConfig { + name: &'static str, + content: &'static str, +} + +/// Nested config files written inside `.ask/docs/` (byte-exact templates). +const NESTED_CONFIGS: &[NestedConfig] = &[ + NestedConfig { + name: ".gitattributes", + content: "# Managed by ASK — marks vendored docs for GitHub Linguist.\n\ + * linguist-vendored=true\n\ + * linguist-generated=true\n", + }, + NestedConfig { + name: "eslint.config.mjs", + content: "// Managed by ASK — vendored docs, excluded from ESLint.\n\ + export default [\n\ + \x20 { ignores: ['**/*'] },\n\ + ]\n", + }, + NestedConfig { + name: "biome.json", + content: "{\n \"$schema\": \"https://biomejs.dev/schemas/2.0.0/schema.json\",\n \"files\": {\n \"ignore\": [\n \"**/*\"\n ]\n }\n}\n", + }, + NestedConfig { + name: ".markdownlint-cli2.jsonc", + content: "{\n \"ignores\": [\n \"**/*\"\n ]\n}\n", + }, +]; + +struct RootPatch { + file: &'static str, + payload: &'static str, + warn: Option<&'static str>, +} + +/// Root files patched only when they already exist (never created from scratch). +const ROOT_PATCHES: &[RootPatch] = &[ + RootPatch { + file: ".prettierignore", + payload: + "# Vendored by ASK\n.ask/docs/\n.ask/skills/\n.ask/resolved.json\n.ask/skills-lock.json", + warn: None, + }, + RootPatch { + file: "sonar-project.properties", + payload: "# Vendored by ASK\nsonar.exclusions=.ask/docs/**,.ask/skills/**", + warn: None, + }, + RootPatch { + file: ".markdownlintignore", + payload: "# Vendored by ASK\n.ask/docs/\n.ask/skills/", + warn: Some( + "Legacy .markdownlintignore detected. Consider migrating to markdownlint-cli2, which \ + supports nested config inside .ask/docs/ automatically.", + ), + }, + RootPatch { + file: ".gitignore", + payload: + "# Vendored by ASK\n.ask/docs/\n.ask/skills/\n.ask/resolved.json\n.ask/skills-lock.json", + warn: None, + }, +]; + +/// Whether the user has opted into ASK via any entry point. +fn has_ask_opt_in(project_dir: &Path) -> bool { + if matches!(read_ask_json(project_dir), Ok(Some(_))) { + return true; + } + let ask = project_dir.join(".ask"); + ask.join("skills-lock.json").exists() || ask.join("skills").exists() +} + +fn rel(project_dir: &Path, target: &Path) -> String { + target + .strip_prefix(project_dir) + .unwrap_or(target) + .to_string_lossy() + .into_owned() +} + +/// Category A: write nested config files inside `.ask/docs/` (only when content +/// differs). Returns project-relative paths written. +pub fn write_nested_configs(project_dir: &Path) -> anyhow::Result> { + let docs_dir = get_docs_dir(project_dir); + std::fs::create_dir_all(&docs_dir)?; + let mut written = Vec::new(); + for cfg in NESTED_CONFIGS { + let target = docs_dir.join(cfg.name); + let existing = std::fs::read_to_string(&target).ok(); + if existing.as_deref() != Some(cfg.content) { + std::fs::write(&target, cfg.content)?; + written.push(rel(project_dir, &target)); + } + } + Ok(written) +} + +/// Remove the nested config files (leaving downloaded docs untouched). +pub fn remove_nested_configs(project_dir: &Path) -> anyhow::Result> { + let docs_dir = get_docs_dir(project_dir); + if !docs_dir.exists() { + return Ok(Vec::new()); + } + let mut removed = Vec::new(); + for cfg in NESTED_CONFIGS { + let target = docs_dir.join(cfg.name); + if target.exists() { + std::fs::remove_file(&target)?; + removed.push(rel(project_dir, &target)); + } + } + Ok(removed) +} + +/// Category C: inject the ASK marker block into existing root files. +pub fn patch_root_ignores(project_dir: &Path) -> anyhow::Result> { + let mut updated = Vec::new(); + for patch in ROOT_PATCHES { + let target = project_dir.join(patch.file); + if !target.exists() { + continue; + } + let existing = std::fs::read_to_string(&target)?; + let block = wrap(patch.payload, MarkerSyntax::Hash); + let next = inject(&existing, &block, MarkerSyntax::Hash); + if next != existing { + std::fs::write(&target, &next)?; + updated.push(patch.file.to_string()); + } + if let Some(warn) = patch.warn { + eprintln!(" Warning: {warn}"); + } + } + Ok(updated) +} + +/// Strip the ASK marker block from patched root files (files never deleted). +pub fn unpatch_root_ignores(project_dir: &Path) -> anyhow::Result> { + let mut updated = Vec::new(); + for patch in ROOT_PATCHES { + let target = project_dir.join(patch.file); + if !target.exists() { + continue; + } + let existing = std::fs::read_to_string(&target)?; + let next = remove_marker(&existing, MarkerSyntax::Hash); + if next != existing { + std::fs::write(&target, &next)?; + updated.push(patch.file.to_string()); + } + } + Ok(updated) +} + +/// Top-level orchestrator. No-op when the user hasn't opted into ASK. +pub fn manage_ignore_files(project_dir: &Path, mode: IgnoreMode) -> anyhow::Result<()> { + if !has_ask_opt_in(project_dir) { + return Ok(()); + } + match mode { + IgnoreMode::Install => { + let nested = write_nested_configs(project_dir)?; + let root = patch_root_ignores(project_dir)?; + if !nested.is_empty() { + eprintln!(" Nested configs written: {}", nested.join(", ")); + } + if !root.is_empty() { + eprintln!(" Root ignore files patched: {}", root.join(", ")); + } + } + IgnoreMode::Remove => { + let nested = remove_nested_configs(project_dir)?; + let root = unpatch_root_ignores(project_dir)?; + if !nested.is_empty() { + eprintln!(" Nested configs removed: {}", nested.join(", ")); + } + if !root.is_empty() { + eprintln!(" Root ignore marker blocks removed: {}", root.join(", ")); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opt_in(project: &Path) { + std::fs::write(crate::io::get_ask_json_path(project), r#"{"libraries":[]}"#).unwrap(); + } + + #[test] + fn no_op_without_opt_in() { + let dir = tempfile::tempdir().unwrap(); + // A .gitignore exists but no ask.json → untouched. + std::fs::write(dir.path().join(".gitignore"), "node_modules\n").unwrap(); + manage_ignore_files(dir.path(), IgnoreMode::Install).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(), + "node_modules\n" + ); + } + + #[test] + fn install_writes_nested_and_patches_existing_root() { + let dir = tempfile::tempdir().unwrap(); + opt_in(dir.path()); + std::fs::write(dir.path().join(".gitignore"), "node_modules\n").unwrap(); + // .prettierignore does NOT exist → must not be created. + manage_ignore_files(dir.path(), IgnoreMode::Install).unwrap(); + + let docs = get_docs_dir(dir.path()); + assert!(docs.join(".gitattributes").exists()); + assert!(docs.join("biome.json").exists()); + // biome.json byte-exact. + assert_eq!( + std::fs::read_to_string(docs.join("biome.json")).unwrap(), + "{\n \"$schema\": \"https://biomejs.dev/schemas/2.0.0/schema.json\",\n \"files\": {\n \"ignore\": [\n \"**/*\"\n ]\n }\n}\n" + ); + // .gitignore patched with a marker block; .prettierignore untouched. + let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(gi.contains("# ask:start")); + assert!(gi.contains(".ask/docs/")); + assert!(!dir.path().join(".prettierignore").exists()); + } + + #[test] + fn remove_strips_nested_and_root() { + let dir = tempfile::tempdir().unwrap(); + opt_in(dir.path()); + std::fs::write(dir.path().join(".gitignore"), "node_modules\n").unwrap(); + manage_ignore_files(dir.path(), IgnoreMode::Install).unwrap(); + manage_ignore_files(dir.path(), IgnoreMode::Remove).unwrap(); + + assert!(!get_docs_dir(dir.path()).join("biome.json").exists()); + let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(!gi.contains("# ask:start")); + assert!(gi.contains("node_modules")); + } + + #[test] + fn nested_write_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + opt_in(dir.path()); + assert!(!write_nested_configs(dir.path()).unwrap().is_empty()); + // Second run: content unchanged → nothing reported. + assert!(write_nested_configs(dir.path()).unwrap().is_empty()); + } +} diff --git a/crates/ask/src/install.rs b/crates/ask/src/install.rs new file mode 100644 index 0000000..d4c0721 --- /dev/null +++ b/crates/ask/src/install.rs @@ -0,0 +1,235 @@ +//! Lazy-first `ask install` orchestrator. Rust port of `install.ts`. +//! +//! Reads `ask.json`, resolves each library's version (github specs carry it in +//! the spec; npm specs read the lockfile), and regenerates AGENTS.md + SKILL.md +//! with lazy `ask src`/`ask docs` references. No docs are downloaded here — +//! agents fetch on-demand through the lazy commands. + +use std::path::Path; + +use crate::agents::{generate_agents_md, LazyLibraryInfo}; +use crate::ask_json::AskJson; +use crate::ignore_files::{manage_ignore_files, IgnoreMode}; +use crate::io::{get_ask_json_path, read_ask_json, write_ask_json}; +use crate::lockfiles::npm_ecosystem_read; +use crate::skill::generate_skill; +use crate::spec::{parse_spec, split_explicit_version, ParsedSpec}; + +/// Options for [`run_install`]. +#[derive(Debug, Clone, Default)] +pub struct RunInstallOptions { + /// Subset of libraries to install (by spec). `None` installs all. + pub only_specs: Option>, +} + +/// Outcome counts from a run. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct InstallSummary { + pub installed: usize, + pub skipped: usize, +} + +pub fn run_install( + project_dir: &Path, + options: &RunInstallOptions, +) -> anyhow::Result { + let Some(ask_json) = read_ask_json(project_dir)? else { + let empty = AskJson { + libraries: Vec::new(), + }; + write_ask_json(project_dir, &empty)?; + eprintln!( + "Created empty {}. Add libraries with `ask add npm:` or \ + `ask add github:/@`.", + get_ask_json_path(project_dir).display() + ); + return Ok(InstallSummary::default()); + }; + + let targets: Vec = ask_json + .libraries + .iter() + .map(|e| e.spec().to_string()) + .filter(|spec| match &options.only_specs { + Some(only) => only.contains(spec), + None => true, + }) + .collect(); + + let mut summary = InstallSummary::default(); + + if targets.is_empty() { + // Full install after the last library was removed still needs to strip + // the AGENTS.md block and ignore markers. + if options.only_specs.is_none() { + generate_agents_md(project_dir, &[]); + manage_ignore_files(project_dir, IgnoreMode::Remove)?; + } + eprintln!("No libraries to install."); + return Ok(summary); + } + + eprintln!( + "Resolving {} librar{}...", + targets.len(), + if targets.len() == 1 { "y" } else { "ies" } + ); + + let mut resolved: Vec = Vec::new(); + for spec in &targets { + match resolve_one(project_dir, spec) { + Some(info) => { + generate_skill(project_dir, &info.name, &info.version)?; + resolved.push(info); + summary.installed += 1; + } + None => summary.skipped += 1, + } + } + + // AGENTS.md reflects ALL resolved libraries. For a scoped install, re-resolve + // the full set; otherwise reuse this batch to avoid duplicate log output. + let all_resolved = if options.only_specs.is_some() { + resolve_all(project_dir) + } else { + resolved + }; + generate_agents_md(project_dir, &all_resolved); + manage_ignore_files(project_dir, IgnoreMode::Install)?; + + eprintln!( + "Install complete: {} resolved, {} skipped.", + summary.installed, summary.skipped + ); + Ok(summary) +} + +/// Resolve a single spec's version. `None` when unresolvable (missing lockfile +/// entry, unsupported ecosystem). +fn resolve_one(project_dir: &Path, spec: &str) -> Option { + let (spec_body, explicit) = split_explicit_version(spec); + match parse_spec(spec_body) { + ParsedSpec::Github { name, .. } => { + // github specs carry the version; strip a leading `v` so output uses + // "^14" not "^v14". + let version = explicit.unwrap_or("latest"); + let version = version.strip_prefix('v').unwrap_or(version).to_string(); + eprintln!(" {spec}: {name}@{version}"); + Some(LazyLibraryInfo { + name, + version, + spec: spec.to_string(), + }) + } + ParsedSpec::Npm { pkg, name } => { + let version = match explicit { + Some(v) => v.to_string(), + None => match npm_ecosystem_read(&pkg, project_dir) { + Some(hit) => hit.version, + None => { + eprintln!(" Warning: {spec}: not found in any lockfile — skipping"); + return None; + } + }, + }; + eprintln!(" {spec}: {name}@{version}"); + Some(LazyLibraryInfo { + name, + version, + spec: spec.to_string(), + }) + } + ParsedSpec::Unknown { .. } => { + eprintln!(" Warning: {spec}: unsupported ecosystem — skipping"); + None + } + } +} + +/// Resolve every library in `ask.json` (for AGENTS.md regeneration). +fn resolve_all(project_dir: &Path) -> Vec { + let Ok(Some(ask_json)) = read_ask_json(project_dir) else { + return Vec::new(); + }; + ask_json + .libraries + .iter() + .filter_map(|entry| resolve_one(project_dir, entry.spec())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_ask(project: &Path, json: &str) { + std::fs::write(get_ask_json_path(project), json).unwrap(); + } + + #[test] + fn creates_empty_ask_json_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let summary = run_install(dir.path(), &RunInstallOptions::default()).unwrap(); + assert_eq!(summary, InstallSummary::default()); + assert!(get_ask_json_path(dir.path()).exists()); + } + + #[test] + fn resolves_github_spec_and_generates_outputs() { + let dir = tempfile::tempdir().unwrap(); + // resolve_one reads the version from the spec string (spec-embedded + // `@v15.0.3`), stripping the leading `v` for the output. + write_ask( + dir.path(), + r#"{"libraries":["github:vercel/next.js@v15.0.3"]}"#, + ); + let summary = run_install(dir.path(), &RunInstallOptions::default()).unwrap(); + assert_eq!(summary.installed, 1); + // SKILL.md + AGENTS.md generated with the v-stripped version. + let skill = std::fs::read_to_string( + crate::skill::get_skill_dir(dir.path(), "next.js").join("SKILL.md"), + ) + .unwrap(); + assert!(skill.contains("next.js v15.0.3")); + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert!(agents.contains("## next.js v15.0.3")); + } + + #[test] + fn npm_spec_without_lockfile_is_skipped() { + let dir = tempfile::tempdir().unwrap(); + write_ask(dir.path(), r#"{"libraries":["npm:acme"]}"#); + let summary = run_install(dir.path(), &RunInstallOptions::default()).unwrap(); + assert_eq!(summary.installed, 0); + assert_eq!(summary.skipped, 1); + } + + #[test] + fn npm_spec_resolves_from_package_json() { + let dir = tempfile::tempdir().unwrap(); + write_ask(dir.path(), r#"{"libraries":["npm:acme"]}"#); + std::fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"acme":"3.4.5"}}"#, + ) + .unwrap(); + let summary = run_install(dir.path(), &RunInstallOptions::default()).unwrap(); + assert_eq!(summary.installed, 1); + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert!(agents.contains("## acme v3.4.5")); + } + + #[test] + fn empty_libraries_strips_agents_block() { + let dir = tempfile::tempdir().unwrap(); + write_ask(dir.path(), r#"{"libraries":[]}"#); + std::fs::write( + dir.path().join("AGENTS.md"), + "# Head\n\n\nx\n\n", + ) + .unwrap(); + run_install(dir.path(), &RunInstallOptions::default()).unwrap(); + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); + assert!(!agents.contains("BEGIN:ask-docs")); + } +} diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 60dc8aa..c3006dc 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -10,6 +10,8 @@ pub mod agents; pub mod ask_json; pub mod cli; pub mod http; +pub mod ignore_files; +pub mod install; pub mod io; pub mod lockfiles; pub mod markers; From e7ca29abf2a22893ae935ac2fdb6fc533029e8f7 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 04:59:04 +0900 Subject: [PATCH 23/44] feat(rust): port list model/renderer + wire ask install & ask list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports list/{model,aggregate,render}.ts + display/table.ts as list.rs, and wires the first two commands to real logic in cli.rs. - list.rs: build_list_model (list_docs → ListModel, detect_conflicts), format_list (header + column table with U+2500 separator + conflicts/warnings), JSON via serde (itemCount/kebab source, matching ListModelSchema). Intent-skills tree deferred (no skills until agents-intent). - cli.rs run(): `ask install` → run_install(cwd); `ask list [--json]` → build_list_model + format_list / to_string_pretty. Remaining commands still NotPorted. End-to-end verified: `ask install` resolves npm:react from package.json and emits AGENTS.md/SKILL.md/ignore configs; `ask list` + `--json` render. 4 new tests, 182 total green. --- crates/ask/src/cli.rs | 45 ++++-- crates/ask/src/lib.rs | 1 + crates/ask/src/list.rs | 333 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+), 15 deletions(-) create mode 100644 crates/ask/src/list.rs diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index 108ed51..e09c079 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -4,6 +4,8 @@ //! command *bodies* are filled in per migration phase. Un-ported commands return //! [`NotPorted`](crate::NotPorted) so the surface is honest about what works. +use std::env::current_dir; + use clap::{Args, Parser, Subcommand}; use crate::NotPorted; @@ -181,22 +183,35 @@ pub struct CacheCleanArgs { pub legacy: bool, } -/// Dispatch a parsed [`Cli`] to its command. Phase 0: every command is a stub -/// returning [`NotPorted`]; phases fill these in bottom-up (see the track plan). +/// Dispatch a parsed [`Cli`] to its command. Ported commands run their real +/// logic; the rest still return [`NotPorted`] until their migration phase. pub fn run(cli: Cli) -> anyhow::Result<()> { - let name = match cli.command { - Command::Install => "install", - Command::Add(_) => "add", - Command::Remove(_) => "remove", - Command::List(_) => "list", - Command::Src(_) => "src", - Command::Docs(_) => "docs", - Command::Fetch(_) => "fetch", - Command::Search(_) => "search", - Command::Skills(_) => "skills", - Command::Cache(_) => "cache", - }; - Err(NotPorted::new(name).into()) + match cli.command { + Command::Install => { + crate::install::run_install(¤t_dir()?, &Default::default())?; + Ok(()) + } + Command::List(args) => run_list(args), + Command::Add(_) => Err(NotPorted::new("add").into()), + Command::Remove(_) => Err(NotPorted::new("remove").into()), + Command::Src(_) => Err(NotPorted::new("src").into()), + Command::Docs(_) => Err(NotPorted::new("docs").into()), + Command::Fetch(_) => Err(NotPorted::new("fetch").into()), + Command::Search(_) => Err(NotPorted::new("search").into()), + Command::Skills(_) => Err(NotPorted::new("skills").into()), + Command::Cache(_) => Err(NotPorted::new("cache").into()), + } +} + +/// `ask list` — render the docs model as text (default) or JSON (`--json`). +fn run_list(args: ListArgs) -> anyhow::Result<()> { + let model = crate::list::build_list_model(¤t_dir()?); + if args.json { + println!("{}", serde_json::to_string_pretty(&model)?); + } else { + println!("{}", crate::list::format_list(&model)); + } + Ok(()) } #[cfg(test)] diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index c3006dc..72966a3 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -13,6 +13,7 @@ pub mod http; pub mod ignore_files; pub mod install; pub mod io; +pub mod list; pub mod lockfiles; pub mod markers; pub mod registry; diff --git a/crates/ask/src/list.rs b/crates/ask/src/list.rs new file mode 100644 index 0000000..d0e795f --- /dev/null +++ b/crates/ask/src/list.rs @@ -0,0 +1,333 @@ +//! `ask list` model + renderer. Rust port of `list/{model,aggregate,render}.ts` +//! and `display/table.ts`. +//! +//! The intent-skills tree (per-skill task/load rows) is omitted until +//! agents-intent lands — intent entries currently carry no skills, so the tree +//! section renders empty exactly as in the TS renderer. + +use std::collections::BTreeMap; +use std::path::Path; + +use serde::Serialize; + +use crate::resolved::EntryFormat; +use crate::storage::{list_docs, ListDocsEntry, ListSource}; + +const EMPTY_MESSAGE: &str = "No libraries declared in ask.json. Use `ask add npm:` or `ask add github:/ --ref ` to get started."; + +/// Where a docs entry's files live (JSON: kebab-case). +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ListEntrySource { + PmDriven, + Github, + Unresolved, +} + +impl From for ListEntrySource { + fn from(s: ListSource) -> Self { + match s { + ListSource::PmDriven => ListEntrySource::PmDriven, + ListSource::Github => ListEntrySource::Github, + ListSource::Unresolved => ListEntrySource::Unresolved, + } + } +} + +/// One row in the list table / JSON array. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ListEntry { + pub name: String, + pub version: String, + pub format: EntryFormat, + pub source: ListEntrySource, + pub location: String, + #[serde(rename = "itemCount")] + pub item_count: u64, +} + +/// A name collision (same name, differing versions). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ListConflict { + pub name: String, + pub versions: Vec, +} + +/// Full `ask list` model (matches `ListModelSchema`). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ListModel { + pub entries: Vec, + pub conflicts: Vec, + pub warnings: Vec, +} + +/// Read the lock-backed view and shape it into a [`ListModel`]. +pub fn build_list_model(project_dir: &Path) -> ListModel { + let raw = list_docs(project_dir); + let conflicts = detect_conflicts(&raw); + let entries = raw + .into_iter() + .map(|e| ListEntry { + name: e.name, + version: e.version, + format: e.format, + source: e.source.into(), + location: e.location, + item_count: e.file_count, + }) + .collect(); + ListModel { + entries, + conflicts, + warnings: Vec::new(), + } +} + +/// Two or more entries sharing a name but differing versions. +fn detect_conflicts(entries: &[ListDocsEntry]) -> Vec { + let mut by_name: BTreeMap> = BTreeMap::new(); + for e in entries { + by_name + .entry(e.name.clone()) + .or_default() + .insert(e.version.clone()); + } + by_name + .into_iter() + .filter(|(_, versions)| versions.len() >= 2) + .map(|(name, versions)| ListConflict { + name, + versions: versions.into_iter().collect(), + }) + .collect() +} + +/// Render the model as user-facing text (empty message when no entries). +pub fn format_list(model: &ListModel) -> String { + if model.entries.is_empty() { + return EMPTY_MESSAGE.to_string(); + } + let mut sections = vec![format_header(model), format_entry_table(&model.entries)]; + if !model.conflicts.is_empty() { + sections.push(format_conflicts(model)); + } + if !model.warnings.is_empty() { + let mut lines = vec!["Warnings:".to_string()]; + lines.extend(model.warnings.iter().map(|w| format!(" {w}"))); + sections.push(lines.join("\n")); + } + sections.join("\n\n") +} + +fn format_header(model: &ListModel) -> String { + let docs = model + .entries + .iter() + .filter(|e| e.format == EntryFormat::Docs) + .count(); + let intent = model + .entries + .iter() + .filter(|e| e.format == EntryFormat::IntentSkills) + .count(); + let mut parts = vec![format!( + "{} {}", + model.entries.len(), + if model.entries.len() == 1 { + "entry" + } else { + "entries" + } + )]; + if docs > 0 { + parts.push(format!("{docs} docs")); + } + if intent > 0 { + parts.push(format!("{intent} intent-skills")); + } + format!("Downloaded documentation: {}", parts.join(", ")) +} + +fn format_entry_table(entries: &[ListEntry]) -> String { + let headers = ["Name", "Version", "Format", "Source", "Items", "Location"]; + let rows: Vec> = entries + .iter() + .map(|e| { + vec![ + e.name.clone(), + e.version.clone(), + format_enum(&e.format), + source_str(e.source).to_string(), + e.item_count.to_string(), + e.location.clone(), + ] + }) + .collect(); + format_table(&headers, &rows) +} + +fn format_conflicts(model: &ListModel) -> String { + let mut lines = vec!["Conflicts:".to_string()]; + for c in &model.conflicts { + lines.push(format!(" {}: {}", c.name, c.versions.join(", "))); + } + lines.join("\n") +} + +fn format_enum(f: &EntryFormat) -> String { + match f { + EntryFormat::Docs => "docs".into(), + EntryFormat::IntentSkills => "intent-skills".into(), + } +} + +fn source_str(s: ListEntrySource) -> &'static str { + match s { + ListEntrySource::PmDriven => "pm-driven", + ListEntrySource::Github => "github", + ListEntrySource::Unresolved => "unresolved", + } +} + +/// Column width = max(header, longest cell) + 2; a U+2500 line under the header. +fn format_table(headers: &[&str], rows: &[Vec]) -> String { + if headers.is_empty() { + return String::new(); + } + let widths: Vec = headers + .iter() + .enumerate() + .map(|(i, h)| { + let max_cell = rows + .iter() + .map(|r| r.get(i).map(|c| char_len(c)).unwrap_or(0)) + .max() + .unwrap_or(0); + char_len(h).max(max_cell) + 2 + }) + .collect(); + + let mut lines = Vec::new(); + lines.push( + headers + .iter() + .enumerate() + .map(|(i, h)| pad_column(h, widths[i])) + .collect::(), + ); + lines.push(widths.iter().map(|w| "─".repeat(*w)).collect::()); + for row in rows { + lines.push( + row.iter() + .enumerate() + .map(|(i, c)| pad_column(c, widths[i])) + .collect::(), + ); + } + lines.join("\n") +} + +fn char_len(s: &str) -> usize { + s.chars().count() +} + +fn pad_column(text: &str, width: usize) -> String { + let len = char_len(text); + if len >= width { + format!("{text} ") + } else { + format!("{text}{}", " ".repeat(width - len)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(project: &Path, ask: &str, resolved: Option<&str>) { + std::fs::create_dir_all(crate::io::get_ask_dir(project)).unwrap(); + std::fs::write(crate::io::get_ask_json_path(project), ask).unwrap(); + if let Some(r) = resolved { + std::fs::write(crate::io::get_resolved_json_path(project), r).unwrap(); + } + } + + #[test] + fn empty_model_renders_message() { + let dir = tempfile::tempdir().unwrap(); + let model = build_list_model(dir.path()); + assert!(model.entries.is_empty()); + assert_eq!(format_list(&model), EMPTY_MESSAGE); + } + + #[test] + fn table_and_header_render() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + r#"{"libraries":["npm:acme","github:o/other"]}"#, + Some( + r#"{"schemaVersion":1,"generatedAt":"2026-01-01T00:00:00Z","entries":{ + "acme":{"spec":"npm:acme","resolvedVersion":"1.2.3", + "contentHash":"sha256-0000000000000000000000000000000000000000000000000000000000000000", + "fetchedAt":"2026-01-01T00:00:00Z","fileCount":3}}}"#, + ), + ); + let model = build_list_model(dir.path()); + let text = format_list(&model); + // Both rows carry format `docs` (an unresolved entry still reports docs). + assert!(text.starts_with("Downloaded documentation: 2 entries, 2 docs")); + assert!(text.contains("Name")); + assert!(text.contains("acme")); + assert!(text.contains("unresolved")); + // Separator line uses U+2500. + assert!(text.contains('─')); + } + + #[test] + fn json_serialization_uses_schema_field_names() { + let model = ListModel { + entries: vec![ListEntry { + name: "acme".into(), + version: "1.0.0".into(), + format: EntryFormat::Docs, + source: ListEntrySource::PmDriven, + location: ".ask/docs/acme@1.0.0".into(), + item_count: 5, + }], + conflicts: vec![], + warnings: vec![], + }; + let json = serde_json::to_string(&model).unwrap(); + assert!(json.contains(r#""itemCount":5"#)); + assert!(json.contains(r#""format":"docs""#)); + assert!(json.contains(r#""source":"pm-driven""#)); + } + + #[test] + fn detects_version_conflicts() { + let raw = vec![ + ListDocsEntry { + name: "acme".into(), + version: "1.0.0".into(), + format: EntryFormat::Docs, + source: ListSource::PmDriven, + spec: "npm:acme".into(), + location: "x".into(), + file_count: 1, + }, + ListDocsEntry { + name: "acme".into(), + version: "2.0.0".into(), + format: EntryFormat::Docs, + source: ListSource::Github, + spec: "github:o/acme".into(), + location: "y".into(), + file_count: 1, + }, + ]; + let conflicts = detect_conflicts(&raw); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].versions, vec!["1.0.0", "2.0.0"]); + } +} From 18eecbb26765c5591bbec0b511e5667bad61e9e0 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:07:47 +0900 Subject: [PATCH 24/44] test(rust): add TS<->Rust differential parity harness for install Runs the same command through the authoritative TypeScript build (packages/cli/dist/cli.js) and the Rust binary against identical fixtures, then byte-diffs the generated files (SKILL.md, AGENTS.md, nested ignore configs, patched root ignores). Self-authored Rust unit tests share a mental model with the code and cannot catch template drift; the TS output is an external oracle. Compares files only, never stdout (consola vs eprintln legitimately differ). Covers install across three fixtures: github-only, npm+github with lockfile resolution and root-ignore patching, and the empty-libraries block-strip path. All identical at time of commit. --- scripts/parity-diff.sh | 102 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100755 scripts/parity-diff.sh diff --git a/scripts/parity-diff.sh b/scripts/parity-diff.sh new file mode 100755 index 0000000..7b639e8 --- /dev/null +++ b/scripts/parity-diff.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# +# Differential parity harness for the bun -> Rust port of the ask CLI. +# +# Runs the SAME command through the authoritative TypeScript build and the +# Rust binary against identical fixtures, then byte-diffs the generated files. +# Self-authored Rust unit tests cannot catch template drift (whitespace, a +# stray newline in SKILL.md / AGENTS.md / the nested ignore configs) because the +# test and the code share one mental model. This harness uses the TS output as +# an external oracle, so any diff is a real signal: fix the Rust side to match. +# +# Only *file outputs* are compared, never stdout: consola (TS) and eprintln! +# (Rust) legitimately differ, and the CLAUDE.md gotcha notes consola goes silent +# under a scrubbed/non-TTY environment anyway. The contract is the files. +# +# Usage: +# scripts/parity-diff.sh # build both, run the built-in fixtures +# scripts/parity-diff.sh --no-build # skip the builds (reuse existing dist/ + target/) +# +# Requires: bun (to build the TS packages), cargo, node, diff. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TS_CLI="$ROOT/packages/cli/dist/cli.js" # bin entry (runMain), NOT dist/index.js +RS_CLI="$ROOT/target/debug/ask" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +build=1 +for arg in "$@"; do + case "$arg" in + --no-build) build=0 ;; + *) echo "unknown arg: $arg" >&2; exit 2 ;; + esac +done + +if [[ "$build" == 1 ]]; then + echo "==> building TS (schema + cli)" + bun run --cwd "$ROOT/packages/schema" build >/dev/null + bun run --cwd "$ROOT/packages/cli" build >/dev/null + echo "==> building Rust (ask-please)" + cargo build --manifest-path "$ROOT/Cargo.toml" -p ask-please >/dev/null 2>&1 +fi + +[[ -f "$TS_CLI" ]] || { echo "missing $TS_CLI (build the TS CLI first)" >&2; exit 1; } +[[ -x "$RS_CLI" ]] || { echo "missing $RS_CLI (build the Rust CLI first)" >&2; exit 1; } + +fail=0 + +# run_case — the fixture is seeded by a `seed_` fn that +# receives the target dir as $1. The command is run in each copy; files diffed. +run_case() { + local name="$1"; shift + local ts="$WORK/$name-ts" rs="$WORK/$name-rs" + mkdir -p "$ts" "$rs" + "seed_$name" "$ts" + "seed_$name" "$rs" + # Scrub env so inherited BUN_*/TTY state cannot perturb either side. + ( cd "$ts" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 node "$TS_CLI" "$@" >/dev/null 2>&1 ) || true + ( cd "$rs" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 "$RS_CLI" "$@" >/dev/null 2>&1 ) || true + if diff -r "$ts" "$rs" >"$WORK/$name.diff" 2>&1; then + echo " ok $name ($*)" + else + echo " FAIL $name ($*)" + sed 's/^/ /' "$WORK/$name.diff" + fail=1 + fi +} + +# --- fixtures -------------------------------------------------------------- + +seed_github_only() { + printf '{\n "libraries": ["github:vercel/next.js@v15.0.3"]\n}\n' >"$1/ask.json" +} + +seed_npm_and_github() { + printf '{\n "libraries": ["npm:react", "npm:zod", "github:vercel/next.js@v15.0.3"]\n}\n' >"$1/ask.json" + printf '{\n "name": "fixture",\n "dependencies": { "react": "^18.2.0", "zod": "3.23.8" }\n}\n' >"$1/package.json" + printf '{\n "lockfileVersion": 3,\n "packages": {\n "node_modules/react": { "version": "18.3.1" },\n "node_modules/zod": { "version": "3.23.8" }\n }\n}\n' >"$1/package-lock.json" + printf '# existing\nnode_modules\ndist\n' >"$1/.gitignore" + printf 'coverage\n' >"$1/.prettierignore" + printf 'sonar.projectKey=demo\nsonar.sources=src\n' >"$1/sonar-project.properties" +} + +seed_empty() { + printf '{\n "libraries": []\n}\n' >"$1/ask.json" +} + +# --- cases ----------------------------------------------------------------- + +echo "==> running parity cases" +run_case github_only install +run_case npm_and_github install +run_case empty install + +if [[ "$fail" == 0 ]]; then + echo "ALL PARITY CASES IDENTICAL" +else + echo "PARITY DIFFERENCES DETECTED — fix the Rust side to match the TS oracle" >&2 + exit 1 +fi From 21a6d93f8ec83cea5b1f22d2de089fc6f1937fe6 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:08:12 +0900 Subject: [PATCH 25/44] chore(rust): sync lockfiles Cargo.lock picks up the source-layer deps (flate2, tar, url, walkdir) added while porting sources/*. bun.lock syncs @pleaseai/ask 0.4.6->0.4.8 to match the release-please package.json bump (stale-lockfile drift). --- Cargo.lock | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ bun.lock | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 22d6347..30b8519 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,15 +79,19 @@ version = "0.4.8" dependencies = [ "anyhow", "clap", + "flate2", "regex", "semver", "serde", "serde_json", "sha2", + "tar", "tempfile", "thiserror", "time", "ureq", + "url", + "walkdir", ] [[package]] @@ -294,6 +298,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -704,6 +718,15 @@ dependencies = [ "untrusted", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "1.0.28" @@ -822,6 +845,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -981,6 +1015,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -996,6 +1040,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1090,6 +1143,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/bun.lock b/bun.lock index aac26a5..c7f26d6 100644 --- a/bun.lock +++ b/bun.lock @@ -50,7 +50,7 @@ }, "packages/cli": { "name": "@pleaseai/ask", - "version": "0.4.6", + "version": "0.4.8", "bin": { "ask": "./dist/cli.js", }, From 7b734ab5274a6633e353a18abb01ad6a9d4a2063 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:12:53 +0900 Subject: [PATCH 26/44] feat(rust): port ensure_checkout, the shared lazy-fetch spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust port of commands/ensure-checkout.ts — the resolver shared by ask src/docs/fetch/add. Splits @version, parses the spec, dispatches to the github source directly (owner/repo/ref) or via an ecosystem resolver (npm/pypi/pub/maven), computes the PM-unified store path, and short- circuits on a cache hit; on miss it fetches through github::fetch unless no_fetch (which raises NoCacheError for the offline-first add path). Preserves the recorded regressions: skip_doc_extraction=true (gitbutler no-docs-folder crash), implicit-default-ref leaves BOTH tag and branch unset so the default-branch fallback (main -> vmain -> master) fires, and the actual-ref reconciliation so a winning fallbackRef / v-rescued ref keeps checkout_dir and ref in agreement. The fetch step is injected via a CheckoutFetcher trait (mirrors TS deps.fetcher) so every path is unit-tested offline: cache hit, bare-spec main default, no_fetch miss -> NoCacheError, npm registry resolution via MockClient, bare-name npm dispatch, and a miss that triggers the fetcher and reports its store_path. 6 new tests, 188 total. --- crates/ask/src/commands/ensure_checkout.rs | 501 +++++++++++++++++++++ crates/ask/src/commands/mod.rs | 7 + crates/ask/src/lib.rs | 1 + 3 files changed, 509 insertions(+) create mode 100644 crates/ask/src/commands/ensure_checkout.rs create mode 100644 crates/ask/src/commands/mod.rs diff --git a/crates/ask/src/commands/ensure_checkout.rs b/crates/ask/src/commands/ensure_checkout.rs new file mode 100644 index 0000000..5419f87 --- /dev/null +++ b/crates/ask/src/commands/ensure_checkout.rs @@ -0,0 +1,501 @@ +//! Shared checkout resolver for `ask src` / `ask docs` / `ask fetch` / `ask add`. +//! Rust port of `commands/ensure-checkout.ts`. +//! +//! Given a spec (optionally `@version`-suffixed), resolve owner/repo/ref, and +//! ensure the GitHub checkout exists in the global store — triggering the +//! `github::fetch` pipeline on a cache miss (unless `no_fetch`). Both `ask src` +//! and `ask docs` share this fetch path, version resolution, and cache layout; +//! they only differ in what they print afterwards. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Result}; + +use crate::http::HttpClient; +use crate::lockfiles::npm_ecosystem_read; +use crate::resolvers::get_resolver; +use crate::sources::github::{self, GithubOptions}; +use crate::sources::FetchResult; +use crate::spec::{parse_spec, split_explicit_version, ParsedSpec}; +use crate::store::{github_store_path, resolve_ask_home}; + +const DEFAULT_GITHUB_HOST: &str = "github.com"; + +/// Options for [`ensure_checkout`]. +#[derive(Debug, Clone)] +pub struct EnsureCheckoutOptions { + /// User-supplied spec, optionally with a trailing `@version` suffix. + pub spec: String, + /// Project root used for lockfile lookups. + pub project_dir: PathBuf, + /// When true, return cache hits only and error with [`NoCacheError`] on miss. + pub no_fetch: bool, +} + +/// Result of a successful [`ensure_checkout`]. +#[derive(Debug, Clone)] +pub struct EnsureCheckoutResult { + pub parsed: ParsedSpec, + pub owner: String, + pub repo: String, + pub reference: String, + pub resolved_version: String, + /// Absolute path to `~/.ask/github/////`. + pub checkout_dir: PathBuf, + /// For npm-ecosystem specs, the package name (e.g. `react`, `@vercel/ai`). + /// Used by `ask docs` to additionally walk `node_modules//`. + pub npm_package_name: Option, + /// True when the checkout was already in the store (no network fetch). + pub from_cache: bool, +} + +/// Error returned when `no_fetch` is set and the checkout is not cached. +/// +/// Carries enough context for callers to print a helpful message and, for +/// `ask add`'s offline-first path, to silently skip the cached-checkout group. +#[derive(Debug, thiserror::Error)] +#[error("no cached checkout for {spec} (expected at {checkout_dir})")] +pub struct NoCacheError { + pub checkout_dir: PathBuf, + pub spec: String, +} + +/// The fetch step, injected so tests can exercise `ensure_checkout` offline. +/// +/// Mirrors TS `deps.fetcher`. The production impl forwards to +/// [`github::fetch`]; a returned `None` models a test seam that materializes +/// the checkout dir without building a real [`FetchResult`]. +pub trait CheckoutFetcher { + fn fetch( + &self, + client: &dyn HttpClient, + opts: &GithubOptions, + ask_home: &Path, + ) -> Result>; +} + +/// Production fetcher: the real `github::fetch` clone/tarball pipeline. +pub struct GithubCheckoutFetcher; + +impl CheckoutFetcher for GithubCheckoutFetcher { + fn fetch( + &self, + client: &dyn HttpClient, + opts: &GithubOptions, + ask_home: &Path, + ) -> Result> { + github::fetch(client, opts, ask_home).map(Some) + } +} + +/// Test seams — production callers pass [`EnsureCheckoutDeps::default`]. +#[derive(Default)] +pub struct EnsureCheckoutDeps<'a> { + /// Override the resolved ASK home (default: [`resolve_ask_home`]). + pub ask_home: Option, + /// Override the fetch step (default: [`GithubCheckoutFetcher`]). + pub fetcher: Option<&'a dyn CheckoutFetcher>, +} + +/// Ensure the GitHub checkout for `spec` exists in the store and return its +/// absolute path. On cache miss, triggers `github::fetch` (bare clone preferred, +/// tar.gz fallback) unless `no_fetch` is set. +pub fn ensure_checkout( + client: &dyn HttpClient, + options: &EnsureCheckoutOptions, + deps: &EnsureCheckoutDeps, +) -> Result { + let ask_home = deps.ask_home.clone().unwrap_or_else(resolve_ask_home); + let default_fetcher = GithubCheckoutFetcher; + let fetcher: &dyn CheckoutFetcher = deps.fetcher.unwrap_or(&default_fetcher); + + // 1. Split @version from the spec, then 2. parse the body. + let (spec_body, explicit_version) = split_explicit_version(&options.spec); + let parsed = parse_spec(spec_body); + + // 3. Determine owner, repo, ref, resolved_version (and npm_package_name). + let owner: String; + let repo: String; + let reference: String; + let resolved_version: String; + let mut npm_package_name: Option = None; + let mut fallback_refs: Vec = Vec::new(); + let mut is_from_branch = false; + // A bare `github:owner/repo` with no explicit @ref defaults to 'main' for the + // cache key, but must leave BOTH tag and branch unset so `github::fetch` + // applies its default-branch fallback chain (main → vmain → master). Passing + // `branch: 'main'` would lock repos whose default is `master` out. + let mut is_implicit_default_ref = false; + + match &parsed { + ParsedSpec::Github { + owner: o, repo: r, .. + } => { + owner = o.clone(); + repo = r.clone(); + reference = explicit_version.unwrap_or("main").to_string(); + resolved_version = reference.clone(); + is_from_branch = explicit_version.is_none(); // 'main' is a branch + is_implicit_default_ref = explicit_version.is_none(); + } + _ => { + // npm-prefixed, bare-name, or another ecosystem prefix → resolver. + let ecosystem = match &parsed { + ParsedSpec::Npm { .. } => "npm", + ParsedSpec::Unknown { ecosystem, .. } if ecosystem.is_empty() => "npm", // bare name + ParsedSpec::Unknown { ecosystem, .. } => ecosystem.as_str(), + ParsedSpec::Github { .. } => unreachable!(), + }; + let pkg_name = match &parsed { + ParsedSpec::Npm { pkg, .. } => pkg.clone(), + ParsedSpec::Unknown { payload, .. } => payload.clone(), + ParsedSpec::Github { .. } => unreachable!(), + }; + if ecosystem == "npm" { + npm_package_name = Some(pkg_name.clone()); + } + + let Some(resolver) = get_resolver(ecosystem) else { + bail!( + "unsupported ecosystem '{ecosystem}' for spec '{}'. \ + Supported ecosystems: npm, pypi, pub, maven", + options.spec + ); + }; + + // Version priority: explicit @version > lockfile (npm only) > 'latest'. + let query_version = match explicit_version { + Some(v) => v.to_string(), + None => { + let from_lock = if ecosystem == "npm" { + npm_ecosystem_read(&pkg_name, &options.project_dir).map(|h| h.version) + } else { + None + }; + from_lock.unwrap_or_else(|| "latest".to_string()) + } + }; + + let result = resolver(client, &pkg_name, &query_version)?; + let Some((o, r)) = result.repo.split_once('/') else { + bail!( + "resolver returned malformed repo '{}' for spec '{}'", + result.repo, + options.spec + ); + }; + owner = o.to_string(); + repo = r.to_string(); + reference = result.ref_; + resolved_version = result.resolved_version; + fallback_refs = result.fallback_refs; + } + } + + // 4. Compute the cache directory (PM-unified layout, shared with + // `github::fetch`). If these two ever diverge, output silently vanishes. + let checkout_dir = + github_store_path(&ask_home, DEFAULT_GITHUB_HOST, &owner, &repo, &reference)?; + + // 5. Cache hit short-circuit. + if checkout_dir.exists() { + return Ok(EnsureCheckoutResult { + parsed, + owner, + repo, + reference, + resolved_version, + checkout_dir, + npm_package_name, + from_cache: true, + }); + } + + // 6. Cache miss + no_fetch → error. + if options.no_fetch { + return Err(NoCacheError { + checkout_dir, + spec: options.spec.clone(), + } + .into()); + } + + // 7. Trigger the fetch. For implicit default refs, pass NEITHER tag nor + // branch so `github::fetch` can activate its default-branch fallback. + // `skip_doc_extraction: true` — callers walk the tree themselves and + // must not fail on repos without a conventional `docs/` folder. + let (branch, tag) = if is_implicit_default_ref { + (None, None) + } else if is_from_branch { + (Some(reference.clone()), None) + } else { + (None, Some(reference.clone())) + }; + let opts = GithubOptions { + name: parsed.name().to_string(), + version: resolved_version.clone(), + repo: format!("{owner}/{repo}"), + branch, + tag, + docs_path: None, + fallback_refs, + remote_url: None, + skip_doc_extraction: true, + }; + let fetch_result = fetcher.fetch(client, &opts, &ask_home)?; + + // 8. Prefer the fetcher's on-disk path: a winning fallbackRef or a + // `v`-rescued ref lands under a DIFFERENT dir than the requested + // ref. Returning the primary-ref path would reproduce the empty-output + // bug on the ref-candidate axis. + let resolved_checkout_dir = fetch_result + .as_ref() + .and_then(|r| r.store_path.clone()) + .unwrap_or_else(|| checkout_dir.clone()); + + // Keep `ref` consistent with the actual checkout dir (its basename), so the + // `ask src --json` contract never reports a ref that disagrees with the path. + let actual_ref = if resolved_checkout_dir == checkout_dir { + reference + } else { + resolved_checkout_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(reference) + }; + + Ok(EnsureCheckoutResult { + parsed, + owner, + repo, + reference: actual_ref, + resolved_version, + checkout_dir: resolved_checkout_dir, + npm_package_name, + from_cache: fetch_result + .as_ref() + .map(|r| r.from_store_cache) + .unwrap_or(false), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + /// A `MockClient` with no routes — resolvers never hit it in cache-hit / + /// direct-github tests, so any call is a bug the panic surfaces. + fn no_http() -> MockClient { + MockClient::new() + } + + fn opts(spec: &str, project_dir: &Path, no_fetch: bool) -> EnsureCheckoutOptions { + EnsureCheckoutOptions { + spec: spec.to_string(), + project_dir: project_dir.to_path_buf(), + no_fetch, + } + } + + /// Fetcher that materializes the checkout dir (models a successful clone) + /// and reports it back via `store_path` — no network, no git. + struct MkdirFetcher { + from_store_cache: bool, + } + impl CheckoutFetcher for MkdirFetcher { + fn fetch( + &self, + _client: &dyn HttpClient, + o: &GithubOptions, + ask_home: &Path, + ) -> Result> { + let (owner, repo) = o.repo.split_once('/').unwrap(); + let reference = o + .tag + .clone() + .or_else(|| o.branch.clone()) + .unwrap_or_else(|| "main".into()); + let dir = github_store_path(ask_home, DEFAULT_GITHUB_HOST, owner, repo, &reference)?; + std::fs::create_dir_all(&dir)?; + Ok(Some(FetchResult { + files: Vec::new(), + resolved_version: o.version.clone(), + store_path: Some(dir), + store_subpath: None, + from_store_cache: self.from_store_cache, + meta: Default::default(), + })) + } + } + + #[test] + fn github_direct_spec_cache_hit() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + // Pre-populate the store for facebook/react @ v18.2.0. + let dir = github_store_path( + home.path(), + DEFAULT_GITHUB_HOST, + "facebook", + "react", + "v18.2.0", + ) + .unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + let res = ensure_checkout( + &no_http(), + &opts("github:facebook/react@v18.2.0", proj.path(), false), + &deps, + ) + .unwrap(); + assert_eq!(res.owner, "facebook"); + assert_eq!(res.repo, "react"); + assert_eq!(res.reference, "v18.2.0"); + assert_eq!(res.checkout_dir, dir); + assert!(res.from_cache); + assert!(res.npm_package_name.is_none()); + } + + #[test] + fn github_bare_spec_defaults_to_main() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = + github_store_path(home.path(), DEFAULT_GITHUB_HOST, "owner", "repo", "main").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + let res = ensure_checkout( + &no_http(), + &opts("github:owner/repo", proj.path(), false), + &deps, + ) + .unwrap(); + assert_eq!(res.reference, "main"); + assert_eq!(res.resolved_version, "main"); + assert!(res.from_cache); + } + + #[test] + fn no_fetch_miss_raises_no_cache_error() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + let err = ensure_checkout( + &no_http(), + &opts("github:owner/repo@v1.2.3", proj.path(), true), + &deps, + ) + .unwrap_err(); + let nce = err.downcast_ref::().expect("NoCacheError"); + assert_eq!(nce.spec, "github:owner/repo@v1.2.3"); + assert!(nce + .checkout_dir + .ends_with("github/github.com/owner/repo/v1.2.3")); + } + + #[test] + fn npm_spec_resolves_via_registry_then_cache_hit() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + // npm resolver expects registry.npmjs.org/; react 18.3.1 → v18.3.1. + let client = MockClient::new().with( + "https://registry.npmjs.org/react", + 200, + r#"{"dist-tags":{},"versions":{"18.3.1":{}},"repository":{"url":"https://github.com/facebook/react.git"}}"#, + ); + // Pre-populate the resolved checkout so no fetch happens. + let dir = github_store_path( + home.path(), + DEFAULT_GITHUB_HOST, + "facebook", + "react", + "v18.3.1", + ) + .unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + let res = ensure_checkout( + &client, + &opts("npm:react@18.3.1", proj.path(), false), + &deps, + ) + .unwrap(); + assert_eq!(res.owner, "facebook"); + assert_eq!(res.repo, "react"); + assert_eq!(res.reference, "v18.3.1"); + assert_eq!(res.resolved_version, "18.3.1"); + assert_eq!(res.npm_package_name.as_deref(), Some("react")); + assert!(res.from_cache); + } + + #[test] + fn cache_miss_triggers_fetch_and_reports_store_path() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let fetcher = MkdirFetcher { + from_store_cache: false, + }; + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: Some(&fetcher), + }; + let res = ensure_checkout( + &no_http(), + &opts("github:owner/repo@v9.9.9", proj.path(), false), + &deps, + ) + .unwrap(); + assert_eq!(res.reference, "v9.9.9"); + assert!(res.checkout_dir.exists()); + assert!(res + .checkout_dir + .ends_with("github/github.com/owner/repo/v9.9.9")); + assert!(!res.from_cache); + } + + #[test] + fn npm_bare_name_treated_as_npm_ecosystem() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let client = MockClient::new().with( + "https://registry.npmjs.org/react", + 200, + r#"{"dist-tags":{},"versions":{"18.3.1":{}},"repository":{"url":"https://github.com/facebook/react.git"}}"#, + ); + let dir = github_store_path( + home.path(), + DEFAULT_GITHUB_HOST, + "facebook", + "react", + "v18.3.1", + ) + .unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + // Bare `react` (no ecosystem prefix) → npm resolver. + let res = + ensure_checkout(&client, &opts("react@18.3.1", proj.path(), false), &deps).unwrap(); + assert_eq!(res.npm_package_name.as_deref(), Some("react")); + assert_eq!(res.reference, "v18.3.1"); + } +} diff --git a/crates/ask/src/commands/mod.rs b/crates/ask/src/commands/mod.rs new file mode 100644 index 0000000..544d004 --- /dev/null +++ b/crates/ask/src/commands/mod.rs @@ -0,0 +1,7 @@ +//! Lazy command implementations (`ask src` / `docs` / `fetch` / `add` / ...). +//! +//! These share the [`ensure_checkout`] resolver, which materializes a spec's +//! GitHub checkout in the global store on demand. Rust port of +//! `packages/cli/src/commands/`. + +pub mod ensure_checkout; diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 72966a3..2f66d9d 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -9,6 +9,7 @@ pub mod agents; pub mod ask_json; pub mod cli; +pub mod commands; pub mod http; pub mod ignore_files; pub mod install; From aa2a1899fa4cefd9a5422d55ab3ab9eb08092460 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:16:29 +0900 Subject: [PATCH 27/44] feat(rust): port ask src + ask fetch on the ensure_checkout spine - ask src : prints the cached source path (or a JSON SrcModel with --json, the stable csp handoff), lazy-fetching on miss unless --no-fetch. run_src returns the stdout string; the CLI wrapper prints it and maps any error (NoCacheError, resolver failure) to stderr + exit 1, matching the TS exit(1) contract rather than the generic exit-2 path. - ask fetch : warms the cache without printing paths; per-spec failures report to stderr and the rest still run, exiting 1 if any failed. run_fetch returns a structured FetchReport (stdout/stderr/had_errors) so the streaming + exit behaviour is unit-testable offline. Fixed SrcArgs to a single spec + --no-fetch + --json (was a specs Vec). 9 new unit tests. Verified byte-identical to the TS build via differential runs against a shared ASK_HOME with a pre-warmed checkout: 'ask src --json' matches the pretty-printed JSON field-for-field, and 'ask fetch' matches the 'already cached' + summary output exactly. 203 tests total. --- crates/ask/src/cli.rs | 61 +++++++++- crates/ask/src/commands/fetch.rs | 185 +++++++++++++++++++++++++++++++ crates/ask/src/commands/mod.rs | 2 + crates/ask/src/commands/src.rs | 163 +++++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 6 deletions(-) create mode 100644 crates/ask/src/commands/fetch.rs create mode 100644 crates/ask/src/commands/src.rs diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index e09c079..0009c0a 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -89,10 +89,12 @@ pub struct ListArgs { #[derive(Debug, Args)] pub struct SrcArgs { - /// One or more specs (e.g. npm:next, github:owner/repo@v1). - #[arg(required = true)] - pub specs: Vec, - /// Emit resolved paths as JSON. + /// Library spec (e.g. react, npm:react@18.2.0, github:facebook/react@v18.2.0). + pub spec: String, + /// Return a cache hit only — exit 1 on cache miss. + #[arg(long = "no-fetch")] + pub no_fetch: bool, + /// Emit the resolution as JSON matching SrcModelSchema. #[arg(long)] pub json: bool, } @@ -194,15 +196,62 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Command::List(args) => run_list(args), Command::Add(_) => Err(NotPorted::new("add").into()), Command::Remove(_) => Err(NotPorted::new("remove").into()), - Command::Src(_) => Err(NotPorted::new("src").into()), + Command::Src(args) => run_src_cmd(args), Command::Docs(_) => Err(NotPorted::new("docs").into()), - Command::Fetch(_) => Err(NotPorted::new("fetch").into()), + Command::Fetch(args) => run_fetch_cmd(args), Command::Search(_) => Err(NotPorted::new("search").into()), Command::Skills(_) => Err(NotPorted::new("skills").into()), Command::Cache(_) => Err(NotPorted::new("cache").into()), } } +/// `ask src ` — print the cached source path (lazy fetch on miss). +/// Failures (NoCacheError, resolver errors) print to stderr and exit 1, +/// matching the TS `exit(1)` contract rather than the generic exit-2 path. +fn run_src_cmd(args: SrcArgs) -> anyhow::Result<()> { + use crate::commands::src::{run_src, RunSrcOptions}; + let client = crate::http::UreqClient::new(); + let options = RunSrcOptions { + spec: args.spec, + project_dir: current_dir()?, + no_fetch: args.no_fetch, + json: args.json, + }; + match run_src(&client, &options) { + Ok(out) => { + println!("{out}"); + Ok(()) + } + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + } +} + +/// `ask fetch ` — warm the cache; per-spec failures still let the +/// rest run, and the process exits 1 if any spec failed. +fn run_fetch_cmd(args: FetchArgs) -> anyhow::Result<()> { + use crate::commands::fetch::{run_fetch, RunFetchOptions}; + let client = crate::http::UreqClient::new(); + let options = RunFetchOptions { + specs: args.specs, + project_dir: current_dir()?, + quiet: args.quiet, + }; + let report = run_fetch(&client, &options); + for line in &report.stdout { + println!("{line}"); + } + for line in &report.stderr { + eprintln!("{line}"); + } + if report.had_errors { + std::process::exit(1); + } + Ok(()) +} + /// `ask list` — render the docs model as text (default) or JSON (`--json`). fn run_list(args: ListArgs) -> anyhow::Result<()> { let model = crate::list::build_list_model(¤t_dir()?); diff --git a/crates/ask/src/commands/fetch.rs b/crates/ask/src/commands/fetch.rs new file mode 100644 index 0000000..dad5075 --- /dev/null +++ b/crates/ask/src/commands/fetch.rs @@ -0,0 +1,185 @@ +//! `ask fetch ` — warm the source cache for one or more specs without +//! printing paths. Rust port of `commands/fetch.ts` (itself ported from +//! opensrc's `opensrc fetch`, vercel-labs/opensrc#53). +//! +//! Per-spec failures are reported and the remaining specs still run; the CLI +//! exits non-zero at the end if any spec failed. `run_fetch` returns a +//! structured report so the streaming/exit behaviour is unit-testable offline. + +use std::path::PathBuf; + +use crate::commands::ensure_checkout::{ + ensure_checkout, EnsureCheckoutDeps, EnsureCheckoutOptions, +}; +use crate::http::HttpClient; + +/// Options for [`run_fetch`]. +#[derive(Debug, Clone)] +pub struct RunFetchOptions { + pub specs: Vec, + pub project_dir: PathBuf, + pub quiet: bool, +} + +/// Outcome of [`run_fetch`]. The CLI writes `stdout` lines to stdout, `stderr` +/// lines to stderr, and exits non-zero when `had_errors`. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct FetchReport { + pub stdout: Vec, + pub stderr: Vec, + pub had_errors: bool, +} + +/// Warm the cache for each spec via the shared checkout helper. +pub fn run_fetch(client: &dyn HttpClient, options: &RunFetchOptions) -> FetchReport { + run_fetch_with(client, options, &EnsureCheckoutDeps::default()) +} + +/// [`run_fetch`] with injectable checkout deps. +pub fn run_fetch_with( + client: &dyn HttpClient, + options: &RunFetchOptions, + deps: &EnsureCheckoutDeps, +) -> FetchReport { + let mut report = FetchReport::default(); + let mut fetched = 0usize; + let mut cached = 0usize; + + for spec in &options.specs { + match ensure_checkout( + client, + &EnsureCheckoutOptions { + spec: spec.clone(), + project_dir: options.project_dir.clone(), + no_fetch: false, + }, + deps, + ) { + Ok(result) => { + // Avoid `spec@ref` duplication when the user already pinned the + // ref in the spec itself (e.g. `github:owner/repo@v1.2.3`). + let suffix = format!("@{}", result.reference); + let display = if spec.ends_with(&suffix) { + spec.clone() + } else { + format!("{spec}{suffix}") + }; + let path = result.checkout_dir.to_string_lossy(); + if result.from_cache { + cached += 1; + if !options.quiet { + report + .stdout + .push(format!(" ✓ {display} already cached ({path})")); + } + } else { + fetched += 1; + if !options.quiet { + report + .stdout + .push(format!(" ✓ Fetched {display} ({path})")); + } + } + } + Err(err) => { + report.had_errors = true; + report.stderr.push(format!(" ✗ {spec}: {err}")); + } + } + } + + if !options.quiet { + let mut parts: Vec = Vec::new(); + if fetched > 0 { + parts.push(format!("{fetched} fetched")); + } + if cached > 0 { + parts.push(format!("{cached} already cached")); + } + if !parts.is_empty() { + report.stdout.push(format!("\n{}", parts.join(", "))); + } + } + + report +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + use crate::store::github_store_path; + + fn deps_home(home: &std::path::Path) -> EnsureCheckoutDeps<'static> { + EnsureCheckoutDeps { + ask_home: Some(home.to_path_buf()), + fetcher: None, + } + } + + #[test] + fn reports_already_cached_without_duplicating_ref() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = github_store_path(home.path(), "github.com", "owner", "repo", "v1.2.3").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let report = run_fetch_with( + &MockClient::new(), + &RunFetchOptions { + specs: vec!["github:owner/repo@v1.2.3".into()], + project_dir: proj.path().to_path_buf(), + quiet: false, + }, + &deps_home(home.path()), + ); + assert!(!report.had_errors); + // The spec already ends with @v1.2.3 — no `@v1.2.3@v1.2.3` duplication. + assert!(report.stdout[0].contains("github:owner/repo@v1.2.3 already cached")); + assert!(!report.stdout[0].contains("v1.2.3@v1.2.3")); + assert!(report.stdout.last().unwrap().contains("1 already cached")); + } + + #[test] + fn per_spec_failure_sets_error_and_continues() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + // First spec resolves (cache hit); second is an unsupported ecosystem. + let dir = github_store_path(home.path(), "github.com", "owner", "repo", "v1.0.0").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let report = run_fetch_with( + &MockClient::new(), + &RunFetchOptions { + specs: vec![ + "github:owner/repo@v1.0.0".into(), + "cargo:serde".into(), // no resolver for cargo + ], + project_dir: proj.path().to_path_buf(), + quiet: false, + }, + &deps_home(home.path()), + ); + assert!(report.had_errors); + assert_eq!(report.stderr.len(), 1); + assert!(report.stderr[0].contains("cargo:serde")); + // The good spec still succeeded. + assert!(report.stdout.iter().any(|l| l.contains("already cached"))); + } + + #[test] + fn quiet_suppresses_stdout_but_keeps_errors() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let report = run_fetch_with( + &MockClient::new(), + &RunFetchOptions { + specs: vec!["cargo:serde".into()], + project_dir: proj.path().to_path_buf(), + quiet: true, + }, + &deps_home(home.path()), + ); + assert!(report.had_errors); + assert!(report.stdout.is_empty()); + assert_eq!(report.stderr.len(), 1); + } +} diff --git a/crates/ask/src/commands/mod.rs b/crates/ask/src/commands/mod.rs index 544d004..46b9d53 100644 --- a/crates/ask/src/commands/mod.rs +++ b/crates/ask/src/commands/mod.rs @@ -5,3 +5,5 @@ //! `packages/cli/src/commands/`. pub mod ensure_checkout; +pub mod fetch; +pub mod src; diff --git a/crates/ask/src/commands/src.rs b/crates/ask/src/commands/src.rs new file mode 100644 index 0000000..703717b --- /dev/null +++ b/crates/ask/src/commands/src.rs @@ -0,0 +1,163 @@ +//! `ask src ` — print the absolute path to a cached library source tree, +//! fetching on cache miss unless `--no-fetch`. Rust port of `commands/src.ts`. +//! +//! `run_src` returns the exact stdout string on success and an error on failure; +//! the thin CLI wrapper prints it and maps any error to a non-zero exit. This +//! keeps the resolution logic unit-testable without process exits or real I/O. + +use std::path::PathBuf; + +use anyhow::Result; +use serde::Serialize; + +use crate::commands::ensure_checkout::{ + ensure_checkout, EnsureCheckoutDeps, EnsureCheckoutOptions, +}; +use crate::http::HttpClient; + +/// Options for [`run_src`]. +#[derive(Debug, Clone)] +pub struct RunSrcOptions { + pub spec: String, + pub project_dir: PathBuf, + pub no_fetch: bool, + pub json: bool, +} + +/// `ask src --json` output — the stable machine-readable handoff that +/// downstream tools (e.g. csp) consume. `checkout_dir` is the version-pinned, +/// content-stable store path they index. Mirrors `SrcModelSchema`. +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct SrcModel { + pub spec: String, + pub owner: String, + pub repo: String, + #[serde(rename = "ref")] + pub reference: String, + #[serde(rename = "resolvedVersion")] + pub resolved_version: String, + #[serde(rename = "checkoutDir")] + pub checkout_dir: String, + #[serde(rename = "npmPackageName")] + pub npm_package_name: Option, +} + +/// Resolve `spec` via the shared checkout helper and return the text to print: +/// the checkout path (default) or a JSON [`SrcModel`] (`json`). On a +/// `NoCacheError` or any resolver failure this returns an error whose message +/// the CLI writes to stderr before exiting non-zero — matching the TS behaviour +/// where both cases route through `error()` + `exit(1)`. +pub fn run_src(client: &dyn HttpClient, options: &RunSrcOptions) -> Result { + run_src_with(client, options, &EnsureCheckoutDeps::default()) +} + +/// [`run_src`] with injectable checkout deps (tests pass an offline fetcher / +/// overridden ASK home). +pub fn run_src_with( + client: &dyn HttpClient, + options: &RunSrcOptions, + deps: &EnsureCheckoutDeps, +) -> Result { + let result = ensure_checkout( + client, + &EnsureCheckoutOptions { + spec: options.spec.clone(), + project_dir: options.project_dir.clone(), + no_fetch: options.no_fetch, + }, + deps, + )?; + + if options.json { + let model = SrcModel { + spec: options.spec.clone(), + owner: result.owner, + repo: result.repo, + reference: result.reference, + resolved_version: result.resolved_version, + checkout_dir: result.checkout_dir.to_string_lossy().into_owned(), + npm_package_name: result.npm_package_name, + }; + Ok(serde_json::to_string_pretty(&model)?) + } else { + Ok(result.checkout_dir.to_string_lossy().into_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::ensure_checkout::NoCacheError; + use crate::http::mock::MockClient; + use crate::store::github_store_path; + + fn deps_home(home: &std::path::Path) -> EnsureCheckoutDeps<'static> { + EnsureCheckoutDeps { + ask_home: Some(home.to_path_buf()), + fetcher: None, + } + } + + #[test] + fn prints_checkout_dir_on_cache_hit() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = + github_store_path(home.path(), "github.com", "facebook", "react", "v18.2.0").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let out = run_src_with( + &MockClient::new(), + &RunSrcOptions { + spec: "github:facebook/react@v18.2.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + json: false, + }, + &deps_home(home.path()), + ) + .unwrap(); + assert_eq!(out, dir.to_string_lossy()); + } + + #[test] + fn json_output_matches_schema_field_names() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = + github_store_path(home.path(), "github.com", "facebook", "react", "v18.2.0").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + let out = run_src_with( + &MockClient::new(), + &RunSrcOptions { + spec: "github:facebook/react@v18.2.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + json: true, + }, + &deps_home(home.path()), + ) + .unwrap(); + assert!(out.contains(r#""ref": "v18.2.0""#)); + assert!(out.contains(r#""resolvedVersion": "v18.2.0""#)); + assert!(out.contains(r#""npmPackageName": null"#)); + assert!(out.contains(r#""checkoutDir""#)); + } + + #[test] + fn no_cache_error_propagates() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let err = run_src_with( + &MockClient::new(), + &RunSrcOptions { + spec: "github:owner/repo@v1.2.3".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: true, + json: false, + }, + &deps_home(home.path()), + ) + .unwrap_err(); + assert!(err.downcast_ref::().is_some()); + } +} From 900f4e0fd78b1bc0909ffc57cf0797e185f33622 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:21:38 +0900 Subject: [PATCH 28/44] feat(rust): port ask docs + find_doc_like_paths on the spine - find_doc_like_paths (find-doc-paths.ts): pre-order DFS returning every /doc/i subdir up to depth 4, dist/docs probed first, falling back to [root] for README-only trees; skips node_modules/.git/.next/.nuxt/dist/build/ coverage + dotdirs. Traversal order mirrors the TS readdirSync walker exactly so the emitted path list is byte-identical. - ask docs (docs.ts): ensure_checkout, then emit doc-like paths from node_modules/ (npm specs only) + the cached checkout. A persisted ask.json docsPaths override, when present and non-stale, restricts output to those pins (resolved against BOTH roots, containment-guarded via assert_contained, existing-file wins); all-stale falls through to the walk with a stderr warning. run_docs returns DocsRun{stdout,warnings} so warnings don't fail; NoCacheError/resolver errors map to exit 1 in the CLI wrapper. DocsArgs gains --no-fetch/--json. 13 new tests. Verified byte-identical to the TS build across --json, text mode, and the docsPaths-override case (walk order matches on APFS since both call the same getdirentries). 207 tests total. --- crates/ask/src/cli.rs | 37 ++- crates/ask/src/commands/docs.rs | 344 ++++++++++++++++++++++ crates/ask/src/commands/find_doc_paths.rs | 145 +++++++++ crates/ask/src/commands/mod.rs | 2 + 4 files changed, 526 insertions(+), 2 deletions(-) create mode 100644 crates/ask/src/commands/docs.rs create mode 100644 crates/ask/src/commands/find_doc_paths.rs diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index 0009c0a..4c5381b 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -101,8 +101,14 @@ pub struct SrcArgs { #[derive(Debug, Args)] pub struct DocsArgs { - /// A single spec (e.g. npm:next, github:owner/repo). + /// Library spec (e.g. react, npm:react@18.2.0, github:facebook/react@v18.2.0). pub spec: String, + /// Return a cache hit only — exit 1 on cache miss. + #[arg(long = "no-fetch")] + pub no_fetch: bool, + /// Emit candidates as JSON matching DocsModelSchema (suppresses per-line output). + #[arg(long)] + pub json: bool, } #[derive(Debug, Args)] @@ -197,7 +203,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Command::Add(_) => Err(NotPorted::new("add").into()), Command::Remove(_) => Err(NotPorted::new("remove").into()), Command::Src(args) => run_src_cmd(args), - Command::Docs(_) => Err(NotPorted::new("docs").into()), + Command::Docs(args) => run_docs_cmd(args), Command::Fetch(args) => run_fetch_cmd(args), Command::Search(_) => Err(NotPorted::new("search").into()), Command::Skills(_) => Err(NotPorted::new("skills").into()), @@ -252,6 +258,33 @@ fn run_fetch_cmd(args: FetchArgs) -> anyhow::Result<()> { Ok(()) } +/// `ask docs ` — print candidate doc paths (lazy fetch on miss). Stale +/// docsPaths warnings go to stderr but do not fail; a NoCacheError / resolver +/// error prints to stderr and exits 1. +fn run_docs_cmd(args: DocsArgs) -> anyhow::Result<()> { + use crate::commands::docs::{run_docs, RunDocsOptions}; + let client = crate::http::UreqClient::new(); + let options = RunDocsOptions { + spec: args.spec, + project_dir: current_dir()?, + no_fetch: args.no_fetch, + json: args.json, + }; + match run_docs(&client, &options) { + Ok(run) => { + for w in &run.warnings { + eprintln!("{w}"); + } + println!("{}", run.stdout); + Ok(()) + } + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + } +} + /// `ask list` — render the docs model as text (default) or JSON (`--json`). fn run_list(args: ListArgs) -> anyhow::Result<()> { let model = crate::list::build_list_model(¤t_dir()?); diff --git a/crates/ask/src/commands/docs.rs b/crates/ask/src/commands/docs.rs new file mode 100644 index 0000000..8e18db6 --- /dev/null +++ b/crates/ask/src/commands/docs.rs @@ -0,0 +1,344 @@ +//! `ask docs ` — print candidate documentation paths from `node_modules` +//! and the cached source tree. Rust port of `commands/docs.ts`. +//! +//! Resolves the spec via the shared `ensure_checkout` helper, then emits doc-like +//! paths (one per line, or a JSON `DocsModel` with `--json`). A persisted +//! `docsPaths` override in ask.json, when present and non-stale, restricts output +//! to those paths (resolved against both node_modules and the checkout). + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use serde::Serialize; + +use crate::commands::ensure_checkout::{ + ensure_checkout, EnsureCheckoutDeps, EnsureCheckoutOptions, +}; +use crate::commands::find_doc_paths::find_doc_like_paths; +use crate::http::HttpClient; +use crate::io::{find_entry, read_ask_json}; +use crate::store::assert_contained; + +/// Options for [`run_docs`]. +#[derive(Debug, Clone)] +pub struct RunDocsOptions { + pub spec: String, + pub project_dir: PathBuf, + pub no_fetch: bool, + pub json: bool, +} + +/// Which source root a candidate path came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DocsRoot { + NodeModules, + Checkout, +} + +/// One documentation candidate path (matches `DocsCandidateSchema`). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct DocsCandidate { + pub path: String, + pub root: DocsRoot, +} + +/// `ask docs --json` output (matches `DocsModelSchema`). +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct DocsModel { + pub spec: String, + #[serde(rename = "npmPackageName")] + pub npm_package_name: Option, + #[serde(rename = "checkoutDir")] + pub checkout_dir: String, + #[serde(rename = "storedOverride")] + pub stored_override: bool, + pub paths: Vec, +} + +/// What [`run_docs`] produces: text to print plus any non-fatal stderr warnings +/// (e.g. all stored docsPaths were stale). The CLI prints `stdout`, writes +/// `warnings` to stderr, and exits 0 — only a hard error (NoCacheError, resolver +/// failure) surfaces as `Err` and maps to exit 1. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct DocsRun { + pub stdout: String, + pub warnings: Vec, +} + +fn candidate_at(p: &Path, root: DocsRoot) -> DocsCandidate { + DocsCandidate { + path: p.to_string_lossy().into_owned(), + root, + } +} + +/// Resolve `spec` and collect documentation candidate paths. +pub fn run_docs(client: &dyn HttpClient, options: &RunDocsOptions) -> Result { + run_docs_with(client, options, &EnsureCheckoutDeps::default()) +} + +/// [`run_docs`] with injectable checkout deps. +pub fn run_docs_with( + client: &dyn HttpClient, + options: &RunDocsOptions, + deps: &EnsureCheckoutDeps, +) -> Result { + let result = ensure_checkout( + client, + &EnsureCheckoutOptions { + spec: options.spec.clone(), + project_dir: options.project_dir.clone(), + no_fetch: options.no_fetch, + }, + deps, + )?; + + let mut run = DocsRun::default(); + let mut paths: Vec = Vec::new(); + + let nm_path = result + .npm_package_name + .as_ref() + .map(|pkg| options.project_dir.join("node_modules").join(pkg)); + + // Persisted docsPaths override: emit ONLY the stored paths (resolved against + // both roots that `ask add` probed). Falls back to the unfiltered walk when + // every stored path is stale — silent empty output would be worse. + let stored: Option> = + read_ask_json(&options.project_dir) + .ok() + .flatten() + .and_then(|aj| { + find_entry(&aj, &options.spec).and_then(|e| e.docs_paths().map(|d| d.to_vec())) + }); + let mut stored_override = false; + + if let Some(stored) = stored.as_ref().filter(|s| !s.is_empty()) { + // Roots in priority order: node_modules (if present) then checkout. + let mut roots: Vec<(PathBuf, DocsRoot)> = Vec::new(); + if let Some(nm) = nm_path.as_ref().filter(|p| p.exists()) { + roots.push((nm.clone(), DocsRoot::NodeModules)); + } + roots.push((result.checkout_dir.clone(), DocsRoot::Checkout)); + + for rel in stored { + for (root, kind) in &roots { + // Containment guard: a `..`/absolute docsPaths entry must not + // escape its root. `join` mirrors path.resolve (absolute rel + // replaces root); assert_contained rejects escapes. + let candidate = root.join(rel); + let Ok(abs) = assert_contained(root, &candidate) else { + continue; + }; + if abs.exists() { + paths.push(candidate_at(&abs, *kind)); + break; + } + } + } + + if !paths.is_empty() { + stored_override = true; + } else { + run.warnings.push(format!( + "ask: stored docsPaths for {} are all stale; emitting all candidates", + options.spec + )); + // fall through to the default walk + } + } + + if !stored_override { + // node_modules// first when the spec is an npm package and it is + // actually installed. Non-npm / missing installs are silently skipped. + if let Some(nm) = nm_path.as_ref().filter(|p| p.exists()) { + for p in find_doc_like_paths(nm) { + paths.push(candidate_at(&p, DocsRoot::NodeModules)); + } + } + // The cached source tree. Always emits the root as the first line even + // when no /doc/i subdirs are found. + for p in find_doc_like_paths(&result.checkout_dir) { + paths.push(candidate_at(&p, DocsRoot::Checkout)); + } + } + + run.stdout = if options.json { + let model = DocsModel { + spec: options.spec.clone(), + npm_package_name: result.npm_package_name.clone(), + checkout_dir: result.checkout_dir.to_string_lossy().into_owned(), + stored_override, + paths, + }; + serde_json::to_string_pretty(&model)? + } else { + paths + .iter() + .map(|c| c.path.as_str()) + .collect::>() + .join("\n") + }; + Ok(run) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::ensure_checkout::NoCacheError; + use crate::http::mock::MockClient; + use crate::store::github_store_path; + + fn deps_home(home: &Path) -> EnsureCheckoutDeps<'static> { + EnsureCheckoutDeps { + ask_home: Some(home.to_path_buf()), + fetcher: None, + } + } + + /// Pre-warm a github checkout and return (home, proj, checkout_dir). + fn warmed() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = github_store_path(home.path(), "github.com", "owner", "repo", "v1.0.0").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + (home, proj, dir) + } + + fn opts(spec: &str, proj: &Path, json: bool) -> RunDocsOptions { + RunDocsOptions { + spec: spec.to_string(), + project_dir: proj.to_path_buf(), + no_fetch: false, + json, + } + } + + #[test] + fn emits_doc_subdir_from_checkout() { + let (home, proj, dir) = warmed(); + std::fs::create_dir_all(dir.join("docs")).unwrap(); + let run = run_docs_with( + &MockClient::new(), + &opts("github:owner/repo@v1.0.0", proj.path(), false), + &deps_home(home.path()), + ) + .unwrap(); + assert!(run.stdout.contains("docs")); + assert!(run.warnings.is_empty()); + } + + #[test] + fn readme_only_checkout_emits_root() { + let (home, proj, dir) = warmed(); + std::fs::write(dir.join("README.md"), "x").unwrap(); + let run = run_docs_with( + &MockClient::new(), + &opts("github:owner/repo@v1.0.0", proj.path(), false), + &deps_home(home.path()), + ) + .unwrap(); + assert_eq!(run.stdout, dir.to_string_lossy()); + } + + #[test] + fn json_model_has_schema_fields() { + let (home, proj, dir) = warmed(); + std::fs::write(dir.join("README.md"), "x").unwrap(); + let run = run_docs_with( + &MockClient::new(), + &opts("github:owner/repo@v1.0.0", proj.path(), true), + &deps_home(home.path()), + ) + .unwrap(); + assert!(run.stdout.contains(r#""storedOverride": false"#)); + assert!(run.stdout.contains(r#""npmPackageName": null"#)); + assert!(run.stdout.contains(r#""root": "checkout""#)); + assert!(run.stdout.contains(r#""checkoutDir""#)); + } + + #[test] + fn stored_docs_paths_override_restricts_output() { + let (home, proj, dir) = warmed(); + std::fs::create_dir_all(dir.join("docs")).unwrap(); + std::fs::create_dir_all(dir.join("guides")).unwrap(); + std::fs::write(dir.join("guides/intro.md"), "x").unwrap(); + // ask.json pins docsPaths to guides/intro.md only. + std::fs::write( + proj.path().join("ask.json"), + r#"{"libraries":[{"spec":"github:owner/repo@v1.0.0","docsPaths":["guides/intro.md"]}]}"#, + ) + .unwrap(); + let run = run_docs_with( + &MockClient::new(), + &opts("github:owner/repo@v1.0.0", proj.path(), true), + &deps_home(home.path()), + ) + .unwrap(); + assert!(run.stdout.contains(r#""storedOverride": true"#)); + assert!(run.stdout.contains("guides/intro.md")); + // Exactly one candidate — the override restricted output to the pin, + // so the unfiltered `docs` subdir walk did not run. + assert_eq!(run.stdout.matches(r#""path""#).count(), 1); + } + + #[test] + fn stale_stored_paths_warn_and_fall_through() { + let (home, proj, dir) = warmed(); + std::fs::write(dir.join("README.md"), "x").unwrap(); + std::fs::write( + proj.path().join("ask.json"), + r#"{"libraries":[{"spec":"github:owner/repo@v1.0.0","docsPaths":["nonexistent/gone.md"]}]}"#, + ) + .unwrap(); + let run = run_docs_with( + &MockClient::new(), + &opts("github:owner/repo@v1.0.0", proj.path(), false), + &deps_home(home.path()), + ) + .unwrap(); + assert_eq!(run.warnings.len(), 1); + assert!(run.warnings[0].contains("all stale")); + // Fell through to the unfiltered walk → root emitted. + assert_eq!(run.stdout, dir.to_string_lossy()); + } + + #[test] + fn containment_guard_rejects_traversal() { + let (home, proj, dir) = warmed(); + std::fs::write(dir.join("README.md"), "x").unwrap(); + // A `..` escape must be rejected, so the override yields nothing → stale. + std::fs::write( + proj.path().join("ask.json"), + r#"{"libraries":[{"spec":"github:owner/repo@v1.0.0","docsPaths":["../../../etc/hosts"]}]}"#, + ) + .unwrap(); + let run = run_docs_with( + &MockClient::new(), + &opts("github:owner/repo@v1.0.0", proj.path(), false), + &deps_home(home.path()), + ) + .unwrap(); + assert_eq!(run.warnings.len(), 1); + assert!(!run.stdout.contains("etc/hosts")); + } + + #[test] + fn no_cache_error_propagates() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let err = run_docs_with( + &MockClient::new(), + &RunDocsOptions { + spec: "github:owner/repo@v9.9.9".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: true, + json: false, + }, + &deps_home(home.path()), + ) + .unwrap_err(); + assert!(err.downcast_ref::().is_some()); + } +} diff --git a/crates/ask/src/commands/find_doc_paths.rs b/crates/ask/src/commands/find_doc_paths.rs new file mode 100644 index 0000000..23b02fe --- /dev/null +++ b/crates/ask/src/commands/find_doc_paths.rs @@ -0,0 +1,145 @@ +//! Documentation-directory discovery for `ask docs`. Rust port of +//! `commands/find-doc-paths.ts`. +//! +//! Walks a source tree and returns every subdirectory whose basename contains +//! "doc" (case-insensitive), up to depth 4, falling back to `[root]` when none +//! exist (README-only packages). The traversal order mirrors the TS walker +//! exactly (pre-order DFS in filesystem order, `dist/docs` probed first) so the +//! emitted path list is identical to the TypeScript build. + +use std::path::{Path, PathBuf}; + +/// Directories that never hold useful docs — skipped wholesale. +const SKIP_DIRS: &[&str] = &[ + "node_modules", + ".git", + ".next", + ".nuxt", + "dist", + "build", + "coverage", +]; + +/// Maximum walk depth. Root counts as depth 0. +const MAX_DEPTH: usize = 4; + +/// Case-insensitive `/doc/i` test on a directory basename. +fn is_doc_dir(name: &str) -> bool { + name.to_ascii_lowercase().contains("doc") +} + +/// Walk `root` and return every subdirectory whose basename matches `/doc/i`, +/// up to depth 4. When none exist, returns `[root]` so small projects whose docs +/// live as a top-level `README.md` still produce a usable path. Returns an empty +/// vec when `root` does not exist (no error). +pub fn find_doc_like_paths(root: &Path) -> Vec { + if !root.exists() { + return Vec::new(); + } + let mut subdirs: Vec = Vec::new(); + + // `dist/docs` is a common publish-time convention (e.g. mastra ships docs + // there). The walker skips `dist/` wholesale, so probe this path explicitly. + let dist_docs = root.join("dist").join("docs"); + if dist_docs.is_dir() { + subdirs.push(dist_docs); + } + + walk(root, 0, &mut subdirs); + + if subdirs.is_empty() { + vec![root.to_path_buf()] + } else { + subdirs + } +} + +fn walk(current_dir: &Path, depth: usize, out: &mut Vec) { + if depth >= MAX_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(current_dir) else { + return; + }; + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if !ft.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') { + continue; + } + let full = current_dir.join(name.as_ref()); + if is_doc_dir(&name) { + out.push(full.clone()); + } + walk(&full, depth + 1, out); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_root_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(find_doc_like_paths(&dir.path().join("nope")).is_empty()); + } + + #[test] + fn readme_only_falls_back_to_root() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("README.md"), "x").unwrap(); + let paths = find_doc_like_paths(dir.path()); + assert_eq!(paths, vec![dir.path().to_path_buf()]); + } + + #[test] + fn finds_doc_subdirs_and_dist_docs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("docs")).unwrap(); + std::fs::create_dir_all(dir.path().join("dist/docs")).unwrap(); + std::fs::create_dir_all(dir.path().join("src")).unwrap(); + let paths = find_doc_like_paths(dir.path()); + // dist/docs probed first, then the docs subdir from the walk. + assert!(paths.iter().any(|p| p.ends_with("dist/docs"))); + assert!(paths + .iter() + .any(|p| p.ends_with("docs") && !p.ends_with("dist/docs"))); + assert!(!paths.iter().any(|p| p.ends_with("src"))); + // dist/docs comes before the walked docs dir. + assert!(paths[0].ends_with("dist/docs")); + } + + #[test] + fn skips_node_modules_and_dotdirs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("node_modules/foo/docs")).unwrap(); + std::fs::create_dir_all(dir.path().join(".hidden/docs")).unwrap(); + std::fs::write(dir.path().join("README.md"), "x").unwrap(); + let paths = find_doc_like_paths(dir.path()); + // Nothing doc-like reachable → fall back to root. + assert_eq!(paths, vec![dir.path().to_path_buf()]); + } + + #[test] + fn case_insensitive_doc_match() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("Documentation")).unwrap(); + let paths = find_doc_like_paths(dir.path()); + assert!(paths.iter().any(|p| p.ends_with("Documentation"))); + } + + #[test] + fn respects_max_depth() { + let dir = tempfile::tempdir().unwrap(); + // depth 5 doc dir is beyond MAX_DEPTH (root=0 .. a/b/c/d=4, doc at depth 5). + std::fs::create_dir_all(dir.path().join("a/b/c/d/docs")).unwrap(); + std::fs::write(dir.path().join("README.md"), "x").unwrap(); + let paths = find_doc_like_paths(dir.path()); + assert_eq!(paths, vec![dir.path().to_path_buf()]); + } +} diff --git a/crates/ask/src/commands/mod.rs b/crates/ask/src/commands/mod.rs index 46b9d53..8848f88 100644 --- a/crates/ask/src/commands/mod.rs +++ b/crates/ask/src/commands/mod.rs @@ -4,6 +4,8 @@ //! GitHub checkout in the global store on demand. Rust port of //! `packages/cli/src/commands/`. +pub mod docs; pub mod ensure_checkout; pub mod fetch; +pub mod find_doc_paths; pub mod src; From d051c7110b7fef432880367725e3f56914150de5 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:24:22 +0900 Subject: [PATCH 29/44] feat(rust): port ask remove + extend parity harness with remove sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ask remove (inline removeCmd in index.ts): match by exact spec → library slug → npm package name (same findIndex rules), splice ask.json, delete the skill, re-run the lazy install to regen AGENTS.md, then re-sync the ignore markers (remove-mode when the list is now empty, else install-mode). run_remove returns a RemoveOutcome enum (NoAskJson / NoMatch / Removed); the CLI maps each to a consola-equivalent line, all exit 0. NOTE: this branch's removeCmd does NOT branch on entry format — intent-skills handling is not present here (intent is deferred), so the port stays faithful to the docs/skill-only teardown. - parity-diff.sh: run_case now runs a SEQUENCE of commands in order, adding install-then-remove cases (remove react from npm+github; remove next.js from github-only) that exercise skill teardown + AGENTS.md regen. All 5 cases byte-identical to the TS build. 5 new tests, 212 total. --- crates/ask/src/cli.rs | 19 +++- crates/ask/src/commands/mod.rs | 1 + crates/ask/src/commands/remove.rs | 158 ++++++++++++++++++++++++++++++ scripts/parity-diff.sh | 16 ++- 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 crates/ask/src/commands/remove.rs diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index 4c5381b..e5c59e7 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -201,7 +201,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { } Command::List(args) => run_list(args), Command::Add(_) => Err(NotPorted::new("add").into()), - Command::Remove(_) => Err(NotPorted::new("remove").into()), + Command::Remove(args) => run_remove_cmd(args), Command::Src(args) => run_src_cmd(args), Command::Docs(args) => run_docs_cmd(args), Command::Fetch(args) => run_fetch_cmd(args), @@ -258,6 +258,23 @@ fn run_fetch_cmd(args: FetchArgs) -> anyhow::Result<()> { Ok(()) } +/// `ask remove ` — drop a library from ask.json and tear down its skill. +fn run_remove_cmd(args: RemoveArgs) -> anyhow::Result<()> { + use crate::commands::remove::{run_remove, RemoveOutcome}; + match run_remove(¤t_dir()?, &args.name)? { + RemoveOutcome::NoAskJson => { + eprintln!("No ask.json found — nothing to remove"); + } + RemoveOutcome::NoMatch(target) => { + eprintln!("No ask.json entry matches '{target}'"); + } + RemoveOutcome::Removed(spec) => { + eprintln!("Removed {spec}"); + } + } + Ok(()) +} + /// `ask docs ` — print candidate doc paths (lazy fetch on miss). Stale /// docsPaths warnings go to stderr but do not fail; a NoCacheError / resolver /// error prints to stderr and exits 1. diff --git a/crates/ask/src/commands/mod.rs b/crates/ask/src/commands/mod.rs index 8848f88..6b506d7 100644 --- a/crates/ask/src/commands/mod.rs +++ b/crates/ask/src/commands/mod.rs @@ -8,4 +8,5 @@ pub mod docs; pub mod ensure_checkout; pub mod fetch; pub mod find_doc_paths; +pub mod remove; pub mod src; diff --git a/crates/ask/src/commands/remove.rs b/crates/ask/src/commands/remove.rs new file mode 100644 index 0000000..caf4efe --- /dev/null +++ b/crates/ask/src/commands/remove.rs @@ -0,0 +1,158 @@ +//! `ask remove ` — remove a library from ask.json and delete its skill. +//! Rust port of the inline `removeCmd` in `index.ts`. +//! +//! Matching mirrors the TS `findIndex`: exact spec, then library slug, then npm +//! package name. After removal it rewrites ask.json, deletes the skill, re-runs +//! the lazy install to regenerate AGENTS.md, and re-syncs the ignore markers. + +use std::path::Path; + +use anyhow::Result; + +use crate::ask_json::LibraryEntry; +use crate::ignore_files::{manage_ignore_files, IgnoreMode}; +use crate::install::{run_install, RunInstallOptions}; +use crate::io::{read_ask_json, write_ask_json}; +use crate::skill::remove_skill; +use crate::spec::{library_name_from_spec, parse_spec, split_explicit_version, ParsedSpec}; + +/// Outcome of [`run_remove`]. The CLI maps each to a consola-equivalent line; +/// all three exit 0 (matching the TS `warn`/`success` behaviour). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoveOutcome { + /// No ask.json present — nothing to remove. + NoAskJson, + /// No entry matched `target`. + NoMatch(String), + /// Removed the entry with this spec. + Removed(String), +} + +/// Remove the entry matching `target` from ask.json (if any) and tear down its +/// generated artifacts. +pub fn run_remove(project_dir: &Path, target: &str) -> Result { + let Some(mut ask_json) = read_ask_json(project_dir)? else { + return Ok(RemoveOutcome::NoAskJson); + }; + + let idx = ask_json + .libraries + .iter() + .position(|entry| entry_matches(entry, target)); + let Some(idx) = idx else { + return Ok(RemoveOutcome::NoMatch(target.to_string())); + }; + + let removed = ask_json.libraries[idx].spec().to_string(); + let (spec_body, _) = split_explicit_version(&removed); + let lib_name = library_name_from_spec(spec_body); + ask_json.libraries.remove(idx); + write_ask_json(project_dir, &ask_json)?; + + remove_skill(project_dir, &lib_name)?; + + // Re-run install to regenerate AGENTS.md with the remaining libraries. + run_install(project_dir, &RunInstallOptions::default())?; + + let mode = if ask_json.libraries.is_empty() { + IgnoreMode::Remove + } else { + IgnoreMode::Install + }; + manage_ignore_files(project_dir, mode)?; + + Ok(RemoveOutcome::Removed(removed)) +} + +/// Match rules, identical to the TS `removeCmd` findIndex: exact spec, then the +/// library slug of the spec body, then the npm package name. +fn entry_matches(entry: &LibraryEntry, target: &str) -> bool { + let spec = entry.spec(); + if spec == target { + return true; + } + let (body, _) = split_explicit_version(spec); + if library_name_from_spec(body) == target { + return true; + } + matches!(parse_spec(body), ParsedSpec::Npm { pkg, .. } if pkg == target) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::io::get_ask_json_path; + + fn write_ask(dir: &Path, json: &str) { + std::fs::write(get_ask_json_path(dir), json).unwrap(); + } + + #[test] + fn no_ask_json_is_reported() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + run_remove(dir.path(), "react").unwrap(), + RemoveOutcome::NoAskJson + ); + } + + #[test] + fn no_match_is_reported() { + let dir = tempfile::tempdir().unwrap(); + write_ask(dir.path(), r#"{"libraries":["npm:react"]}"#); + assert_eq!( + run_remove(dir.path(), "vue").unwrap(), + RemoveOutcome::NoMatch("vue".into()) + ); + } + + #[test] + fn removes_by_npm_package_name() { + let dir = tempfile::tempdir().unwrap(); + write_ask(dir.path(), r#"{"libraries":["npm:react","npm:vue"]}"#); + let out = run_remove(dir.path(), "react").unwrap(); + assert_eq!(out, RemoveOutcome::Removed("npm:react".into())); + // ask.json now holds only vue. + let aj = read_ask_json(dir.path()).unwrap().unwrap(); + assert_eq!(aj.libraries.len(), 1); + assert_eq!(aj.libraries[0].spec(), "npm:vue"); + } + + #[test] + fn removes_by_exact_spec_and_deletes_skill() { + let dir = tempfile::tempdir().unwrap(); + write_ask( + dir.path(), + r#"{"libraries":["github:vercel/next.js@v15.0.3"]}"#, + ); + // Materialize the skill first so we can assert its teardown. + run_install(dir.path(), &RunInstallOptions::default()).unwrap(); + let skill = crate::skill::get_skill_dir(dir.path(), "next.js").join("SKILL.md"); + assert!(skill.exists()); + + let out = run_remove(dir.path(), "github:vercel/next.js@v15.0.3").unwrap(); + assert_eq!( + out, + RemoveOutcome::Removed("github:vercel/next.js@v15.0.3".into()) + ); + assert!(!skill.exists()); + // AGENTS.md block stripped (no libraries left). + let agents = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap_or_default(); + assert!(!agents.contains("BEGIN:ask-docs")); + } + + #[test] + fn removes_by_library_slug() { + let dir = tempfile::tempdir().unwrap(); + write_ask( + dir.path(), + r#"{"libraries":["github:vercel/next.js@v15.0.3"]}"#, + ); + // The slug of `github:vercel/next.js` is `next.js`. + let out = run_remove(dir.path(), "next.js").unwrap(); + assert_eq!( + out, + RemoveOutcome::Removed("github:vercel/next.js@v15.0.3".into()) + ); + } +} diff --git a/scripts/parity-diff.sh b/scripts/parity-diff.sh index 7b639e8..e9ef27d 100755 --- a/scripts/parity-diff.sh +++ b/scripts/parity-diff.sh @@ -56,9 +56,14 @@ run_case() { mkdir -p "$ts" "$rs" "seed_$name" "$ts" "seed_$name" "$rs" + # Each remaining arg is ONE command line (word-split here), run in order in + # both copies — supports sequences like `install` then `remove react`. # Scrub env so inherited BUN_*/TTY state cannot perturb either side. - ( cd "$ts" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 node "$TS_CLI" "$@" >/dev/null 2>&1 ) || true - ( cd "$rs" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 "$RS_CLI" "$@" >/dev/null 2>&1 ) || true + local cmd + for cmd in "$@"; do + ( cd "$ts" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 node "$TS_CLI" $cmd >/dev/null 2>&1 ) || true + ( cd "$rs" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 "$RS_CLI" $cmd >/dev/null 2>&1 ) || true + done if diff -r "$ts" "$rs" >"$WORK/$name.diff" 2>&1; then echo " ok $name ($*)" else @@ -87,12 +92,19 @@ seed_empty() { printf '{\n "libraries": []\n}\n' >"$1/ask.json" } +# Reuse the same seeds for the install-then-remove sequences. +seed_npm_and_github_rm() { seed_npm_and_github "$1"; } +seed_github_only_rm() { seed_github_only "$1"; } + # --- cases ----------------------------------------------------------------- echo "==> running parity cases" run_case github_only install run_case npm_and_github install run_case empty install +# Sequences: install then remove — exercises skill teardown + AGENTS.md regen. +run_case npm_and_github_rm install "remove react" +run_case github_only_rm install "remove next.js" if [[ "$fail" == 0 ]]; then echo "ALL PARITY CASES IDENTICAL" From ff084af940aeaa697326cd282dc01aa19171dd5f Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:29:15 +0900 Subject: [PATCH 30/44] feat(rust): port ask search + resolve_csp (optional csp delegation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve_csp (resolve-csp.ts): CSP_BIN override (no probe) → PATH scan honouring PATHEXT on Windows (dropping .cmd/.bat/.ps1 shims ask can't spawn) and the exec bit on POSIX → None. Injectable CspEnv for offline tests. - ask search (search.ts): ensure_checkout to a version-pinned checkout, then delegate to csp. csp is OPTIONAL — when absent, print the checkout path + a shell-quoted copy-paste recipe and exit 0 (never fail solely because csp is missing). When present, spawn with inherited stdio and forward the exit code (a signal-killed csp maps to 128+signum, never a bogus 0). Pure helpers ported with exact vectors: csp_exit_code, shell_quote (POSIX single-quote with '\'' escaping), build_csp_args (path positional after query, one --content per value, --top-k). run_search returns SearchReport {stdout,stderr,exit_code}; the CLI wrapper parses --content csv + --top-k (warning on non-numeric) and process::exit(exit_code). 12 new tests, 224 total. Verified end-to-end against the TS build: with CSP_BIN pointed at a fake csp, BOTH builds spawn it with byte-identical argv (search, query, checkoutDir, --content docs, --content code, --top-k 7) and forward its exit code 42. --- crates/ask/src/cli.rs | 64 ++++- crates/ask/src/commands/mod.rs | 2 + crates/ask/src/commands/resolve_csp.rs | 183 +++++++++++++ crates/ask/src/commands/search.rs | 354 +++++++++++++++++++++++++ 4 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 crates/ask/src/commands/resolve_csp.rs create mode 100644 crates/ask/src/commands/search.rs diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index e5c59e7..fab5fa1 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -127,6 +127,15 @@ pub struct SearchArgs { pub spec: String, /// The search query. pub query: String, + /// csp content filter(s), comma-separated: code | docs | config | all. + #[arg(long)] + pub content: Option, + /// Max results to return (csp --top-k). + #[arg(long = "top-k")] + pub top_k: Option, + /// Return a cache hit only — exit 1 on cache miss. + #[arg(long = "no-fetch")] + pub no_fetch: bool, } #[derive(Debug, Args)] @@ -205,7 +214,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Command::Src(args) => run_src_cmd(args), Command::Docs(args) => run_docs_cmd(args), Command::Fetch(args) => run_fetch_cmd(args), - Command::Search(_) => Err(NotPorted::new("search").into()), + Command::Search(args) => run_search_cmd(args), Command::Skills(_) => Err(NotPorted::new("skills").into()), Command::Cache(_) => Err(NotPorted::new("cache").into()), } @@ -258,6 +267,59 @@ fn run_fetch_cmd(args: FetchArgs) -> anyhow::Result<()> { Ok(()) } +/// `ask search ` — delegate a semantic search to csp (optional). +fn run_search_cmd(args: SearchArgs) -> anyhow::Result<()> { + use crate::commands::resolve_csp::resolve_csp_default; + use crate::commands::search::{run_search, spawn_csp, RunSearchOptions, SearchDeps}; + + let content: Vec = args + .content + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| { + s.split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() + }) + .unwrap_or_default(); + // Don't silently drop a garbage --top-k: warn so the user knows csp ran with + // its own default rather than the value they typed. + let top_k = match args.top_k.as_deref().filter(|s| !s.is_empty()) { + Some(raw) => match raw.parse::() { + Ok(n) => Some(n), + Err(_) => { + eprintln!("ask: ignoring invalid --top-k '{raw}' (not a number)"); + None + } + }, + None => None, + }; + + let client = crate::http::UreqClient::new(); + let deps = SearchDeps { + checkout: Default::default(), + resolve_csp: &resolve_csp_default, + run_csp: &spawn_csp, + }; + let options = RunSearchOptions { + spec: args.spec, + query: args.query, + project_dir: current_dir()?, + no_fetch: args.no_fetch, + content, + top_k, + }; + let report = run_search(&client, &options, &deps); + for line in &report.stdout { + println!("{line}"); + } + for line in &report.stderr { + eprintln!("{line}"); + } + std::process::exit(report.exit_code); +} + /// `ask remove ` — drop a library from ask.json and tear down its skill. fn run_remove_cmd(args: RemoveArgs) -> anyhow::Result<()> { use crate::commands::remove::{run_remove, RemoveOutcome}; diff --git a/crates/ask/src/commands/mod.rs b/crates/ask/src/commands/mod.rs index 6b506d7..7d52274 100644 --- a/crates/ask/src/commands/mod.rs +++ b/crates/ask/src/commands/mod.rs @@ -9,4 +9,6 @@ pub mod ensure_checkout; pub mod fetch; pub mod find_doc_paths; pub mod remove; +pub mod resolve_csp; +pub mod search; pub mod src; diff --git a/crates/ask/src/commands/resolve_csp.rs b/crates/ask/src/commands/resolve_csp.rs new file mode 100644 index 0000000..30d48ea --- /dev/null +++ b/crates/ask/src/commands/resolve_csp.rs @@ -0,0 +1,183 @@ +//! Locate the optional `csp` (code-search) binary for `ask search`. Rust port +//! of `commands/resolve-csp.ts`. +//! +//! ask spawns csp as a separate process (the contract is a path, not an +//! in-process API) and csp is OPTIONAL — ask must never fail solely because csp +//! is absent. Resolution order: `CSP_BIN` env override → `csp` on `PATH` → None. + +use std::path::{Path, PathBuf}; + +/// Windows extensions that need a shell interpreter — `ask search` spawns csp +/// without a shell, so these are never resolvable. +const SHELL_ONLY_EXTS: &[&str] = &[".cmd", ".bat", ".ps1"]; + +/// Environment inputs for [`resolve_csp`], injectable for tests. +pub struct CspEnv<'a> { + pub csp_bin: Option, + pub path: Option, + pub pathext: Option, + pub is_win: bool, + /// Returns true when `p` exists and is executable. + pub is_executable: &'a dyn Fn(&Path) -> bool, +} + +/// Locate csp. `CSP_BIN` wins with no existence probe (let the spawn fail loudly +/// if bogus); otherwise scan `PATH`, honouring `PATHEXT` on Windows and the +/// executable bit on POSIX. +pub fn resolve_csp(env: &CspEnv) -> Option { + // 1. Explicit override wins, no probe. + if let Some(override_) = env + .csp_bin + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + return Some(override_.to_string()); + } + + // 2. PATH scan. + let path_var = env.path.as_deref().unwrap_or(""); + if path_var.is_empty() { + return None; + } + + // csp is spawned WITHOUT a shell, so probe only shell-free executables and + // drop `.cmd`/`.bat`/`.ps1` even if the user's PATHEXT lists them. + let exts: Vec = if env.is_win { + env.pathext + .as_deref() + .unwrap_or(".EXE;.COM") + .split(';') + .filter(|e| !e.is_empty()) + .filter(|e| !SHELL_ONLY_EXTS.contains(&e.to_ascii_lowercase().as_str())) + .map(|e| e.to_string()) + .collect() + } else { + vec![String::new()] + }; + + let sep = if env.is_win { ';' } else { ':' }; + for dir in path_var.split(sep) { + if dir.is_empty() { + continue; + } + for ext in &exts { + let candidate = PathBuf::from(dir).join(format!("csp{}", ext.to_ascii_lowercase())); + if (env.is_executable)(&candidate) { + return Some(candidate.to_string_lossy().into_owned()); + } + if env.is_win { + let upper = PathBuf::from(dir).join(format!("csp{}", ext.to_ascii_uppercase())); + if (env.is_executable)(&upper) { + return Some(upper.to_string_lossy().into_owned()); + } + } + } + } + + None +} + +/// Real executability probe: exists, is a file, and (on unix) has an exec bit. +pub fn default_is_executable(p: &Path) -> bool { + let Ok(meta) = std::fs::metadata(p) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// Production [`CspEnv`] built from the real process environment. +pub fn resolve_csp_default() -> Option { + let env = CspEnv { + csp_bin: std::env::var("CSP_BIN").ok(), + path: std::env::var("PATH") + .ok() + .or_else(|| std::env::var("Path").ok()), + pathext: std::env::var("PATHEXT").ok(), + is_win: cfg!(windows), + is_executable: &default_is_executable, + }; + resolve_csp(&env) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn csp_bin_override_wins_without_probe() { + let never = |_: &Path| false; + let env = CspEnv { + csp_bin: Some(" /custom/csp ".into()), + path: Some("/usr/bin".into()), + pathext: None, + is_win: false, + is_executable: &never, + }; + assert_eq!(resolve_csp(&env).as_deref(), Some("/custom/csp")); + } + + #[test] + fn empty_override_falls_through_to_path() { + let match_bin = |p: &Path| p == Path::new("/opt/bin/csp"); + let env = CspEnv { + csp_bin: Some(" ".into()), + path: Some("/usr/bin:/opt/bin".into()), + pathext: None, + is_win: false, + is_executable: &match_bin, + }; + assert_eq!(resolve_csp(&env).as_deref(), Some("/opt/bin/csp")); + } + + #[test] + fn absent_from_path_returns_none() { + let never = |_: &Path| false; + let env = CspEnv { + csp_bin: None, + path: Some("/usr/bin:/opt/bin".into()), + pathext: None, + is_win: false, + is_executable: &never, + }; + assert_eq!(resolve_csp(&env), None); + } + + #[test] + fn empty_path_returns_none() { + let never = |_: &Path| false; + let env = CspEnv { + csp_bin: None, + path: None, + pathext: None, + is_win: false, + is_executable: &never, + }; + assert_eq!(resolve_csp(&env), None); + } + + #[test] + fn windows_drops_shell_only_extensions() { + // Only csp.cmd exists — a shell shim ask can't spawn — so resolution fails. + let match_cmd = |p: &Path| p.to_string_lossy().to_lowercase().ends_with("csp.cmd"); + let env = CspEnv { + csp_bin: None, + path: Some("C:\\bin".into()), + pathext: Some(".CMD;.EXE".into()), + is_win: true, + is_executable: &match_cmd, + }; + assert_eq!(resolve_csp(&env), None); + } +} diff --git a/crates/ask/src/commands/search.rs b/crates/ask/src/commands/search.rs new file mode 100644 index 0000000..8fd70a4 --- /dev/null +++ b/crates/ask/src/commands/search.rs @@ -0,0 +1,354 @@ +//! `ask search ` — semantic code search over a version-pinned +//! checkout, delegating to the optional `csp` binary. Rust port of +//! `commands/search.ts`. +//! +//! Acquisition (ensure_checkout) feeds retrieval (csp). csp is optional: when it +//! is absent, ask prints the resolved checkout path plus a copy-pasteable recipe +//! and exits 0 — it never fails solely because csp is missing. + +use std::path::PathBuf; + +use crate::commands::ensure_checkout::{ + ensure_checkout, EnsureCheckoutDeps, EnsureCheckoutOptions, +}; +use crate::http::HttpClient; + +/// Options for [`run_search`]. +#[derive(Debug, Clone)] +pub struct RunSearchOptions { + pub spec: String, + pub query: String, + pub project_dir: PathBuf, + pub no_fetch: bool, + /// Maps to csp `--content` (repeatable: code | docs | config | all). + pub content: Vec, + /// Maps to csp `--top-k`. + pub top_k: Option, +} + +/// Outcome of running csp — status is the normal exit code; signal is set when +/// the child was terminated by a signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CspRunResult { + pub status: Option, + pub signal: Option, +} + +/// Map a csp result to a process exit code. A normal exit forwards `status`; a +/// signal-terminated child forwards the shell convention `128 + signum` so a +/// crashed csp is not reported as success; a truly-empty result falls back to 0. +pub fn csp_exit_code(result: &CspRunResult) -> i32 { + if let Some(status) = result.status { + return status; + } + if let Some(signal) = result.signal { + return 128 + signal; + } + 0 +} + +/// True when `token` is safe to print bare in a shell recipe. +fn is_shell_safe(token: &str) -> bool { + !token.is_empty() + && token + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_.:/@%+=-".contains(c)) +} + +/// POSIX-shell-quote a recipe token so the printed csp command is copy-paste +/// safe. Display-only — the real csp call passes an argv array, never a shell. +fn shell_quote(token: &str) -> String { + if is_shell_safe(token) { + token.to_string() + } else { + format!("'{}'", token.replace('\'', r"'\''")) + } +} + +/// Build the csp argv: `search ` with the path as a +/// POSITIONAL after the query, one `--content ` per selection, and +/// `--top-k ` when set. +pub fn build_csp_args( + query: &str, + checkout_dir: &str, + content: &[String], + top_k: Option, +) -> Vec { + let mut args = vec![ + "search".to_string(), + query.to_string(), + checkout_dir.to_string(), + ]; + for c in content { + args.push("--content".to_string()); + args.push(c.clone()); + } + if let Some(k) = top_k { + args.push("--top-k".to_string()); + args.push(k.to_string()); + } + args +} + +/// What [`run_search`] produces: lines for stdout/stderr and the exit code the +/// CLI should terminate with. When csp runs, it streams through inherited stdio +/// so its output is NOT in `stdout` — only ask-level messages are. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct SearchReport { + pub stdout: Vec, + pub stderr: Vec, + pub exit_code: i32, +} + +/// Test/production seams for [`run_search`]. +pub struct SearchDeps<'a> { + pub checkout: EnsureCheckoutDeps<'a>, + /// Locate csp (default: [`resolve_csp_default`]). + pub resolve_csp: &'a dyn Fn() -> Option, + /// Run csp (default: spawn with inherited stdio). + pub run_csp: &'a dyn Fn(&str, &[String]) -> anyhow::Result, +} + +/// Resolve the spec to a checkout and delegate the query to csp. +pub fn run_search( + client: &dyn HttpClient, + options: &RunSearchOptions, + deps: &SearchDeps, +) -> SearchReport { + let mut report = SearchReport::default(); + + let result = match ensure_checkout( + client, + &EnsureCheckoutOptions { + spec: options.spec.clone(), + project_dir: options.project_dir.clone(), + no_fetch: options.no_fetch, + }, + &deps.checkout, + ) { + Ok(r) => r, + Err(err) => { + report.stderr.push(format!("{err}")); + report.exit_code = 1; + return report; + } + }; + + let checkout_dir = result.checkout_dir.to_string_lossy().into_owned(); + let csp_args = build_csp_args( + &options.query, + &checkout_dir, + &options.content, + options.top_k, + ); + + let Some(csp) = (deps.resolve_csp)() else { + // Graceful degradation: no csp → path + runnable recipe, exit 0. + let mut recipe = String::from("csp"); + for a in &csp_args { + recipe.push(' '); + recipe.push_str(&shell_quote(a)); + } + report.stderr.push( + "ask: csp (code-search) not found on PATH or $CSP_BIN — printing checkout path + recipe." + .to_string(), + ); + report.stdout.push(checkout_dir); + report.stdout.push(recipe); + report.exit_code = 0; + return report; + }; + + match (deps.run_csp)(&csp, &csp_args) { + Ok(res) => report.exit_code = csp_exit_code(&res), + Err(err) => { + report + .stderr + .push(format!("ask: failed to run csp ({csp}): {err}")); + report.exit_code = 1; + } + } + report +} + +/// Production csp runner: spawn with inherited stdio and capture status/signal. +pub fn spawn_csp(bin: &str, args: &[String]) -> anyhow::Result { + use std::process::Command; + let status = Command::new(bin).args(args).status()?; + #[cfg(unix)] + let signal = { + use std::os::unix::process::ExitStatusExt; + status.signal() + }; + #[cfg(not(unix))] + let signal = None; + Ok(CspRunResult { + status: status.code(), + signal, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + use crate::store::github_store_path; + + #[test] + fn csp_exit_code_forwards_status_signal_or_zero() { + assert_eq!( + csp_exit_code(&CspRunResult { + status: Some(3), + signal: None + }), + 3 + ); + assert_eq!( + csp_exit_code(&CspRunResult { + status: None, + signal: Some(9) + }), + 137 + ); + assert_eq!( + csp_exit_code(&CspRunResult { + status: None, + signal: None + }), + 0 + ); + } + + #[test] + fn shell_quote_bare_and_quoted() { + assert_eq!(shell_quote("react"), "react"); + assert_eq!(shell_quote("a/b:c@1.0"), "a/b:c@1.0"); + assert_eq!(shell_quote("hello world"), "'hello world'"); + assert_eq!(shell_quote("it's"), r"'it'\''s'"); + assert_eq!(shell_quote(""), "''"); + } + + #[test] + fn build_csp_args_shape() { + let args = build_csp_args("q", "/co", &["docs".into(), "code".into()], Some(5)); + assert_eq!( + args, + vec![ + "search", + "q", + "/co", + "--content", + "docs", + "--content", + "code", + "--top-k", + "5" + ] + ); + } + + fn warmed() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = github_store_path(home.path(), "github.com", "o", "r", "v1.0.0").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + (home, proj, dir) + } + + fn opts(project_dir: &std::path::Path) -> RunSearchOptions { + RunSearchOptions { + spec: "github:o/r@v1.0.0".into(), + query: "hooks".into(), + project_dir: project_dir.to_path_buf(), + no_fetch: false, + content: vec![], + top_k: None, + } + } + + #[test] + fn no_csp_prints_path_and_recipe_exit_zero() { + let (home, proj, dir) = warmed(); + let no_csp = || None; + let never_run = |_: &str, _: &[String]| -> anyhow::Result { + panic!("csp must not run when absent") + }; + let deps = SearchDeps { + checkout: EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }, + resolve_csp: &no_csp, + run_csp: &never_run, + }; + let report = run_search(&MockClient::new(), &opts(proj.path()), &deps); + assert_eq!(report.exit_code, 0); + assert_eq!(report.stdout[0], dir.to_string_lossy()); + assert!(report.stdout[1].starts_with("csp search hooks ")); + assert!(report.stderr[0].contains("not found")); + } + + #[test] + fn csp_present_forwards_exit_code() { + let (home, proj, _dir) = warmed(); + let have_csp = || Some("/bin/csp".to_string()); + let run = |_: &str, _: &[String]| -> anyhow::Result { + Ok(CspRunResult { + status: Some(2), + signal: None, + }) + }; + let deps = SearchDeps { + checkout: EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }, + resolve_csp: &have_csp, + run_csp: &run, + }; + let report = run_search(&MockClient::new(), &opts(proj.path()), &deps); + assert_eq!(report.exit_code, 2); + assert!(report.stdout.is_empty()); + } + + #[test] + fn csp_spawn_failure_exit_one() { + let (home, proj, _dir) = warmed(); + let have_csp = || Some("/bin/csp".to_string()); + let run = + |_: &str, _: &[String]| -> anyhow::Result { anyhow::bail!("ENOEXEC") }; + let deps = SearchDeps { + checkout: EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }, + resolve_csp: &have_csp, + run_csp: &run, + }; + let report = run_search(&MockClient::new(), &opts(proj.path()), &deps); + assert_eq!(report.exit_code, 1); + assert!(report.stderr[0].contains("failed to run csp")); + } + + #[test] + fn no_cache_error_exit_one() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let no_csp = || None; + let never_run = + |_: &str, _: &[String]| -> anyhow::Result { panic!("unreachable") }; + let deps = SearchDeps { + checkout: EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }, + resolve_csp: &no_csp, + run_csp: &never_run, + }; + let mut o = opts(proj.path()); + o.no_fetch = true; // miss + no_fetch → NoCacheError + let report = run_search(&MockClient::new(), &o, &deps); + assert_eq!(report.exit_code, 1); + assert!(report.stderr[0].contains("no cached checkout")); + } +} From 0109f90e6aeab7916dd6014c0fe335e05e43701d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:33:58 +0900 Subject: [PATCH 31/44] feat(rust): port ask cache (ls/gc/clean) + store/cache.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cache_ls: enumerates npm/web/llms-txt kind dirs + the nested github///// layout, plus the legacy github/checkouts layout tagged legacy with a '(legacy) ' key prefix. Ordering matches TS (npm → github → web → llms-txt). - cache_gc: removes entries not referenced by any .ask/resolved.json under the scan roots (default [$HOME], overridable via ASK_GC_SCAN_ROOTS), with an optional --older-than age gate; storePath values are containment-checked against ask_home via assert_contained so a stale/malicious resolved.json can't pin entries outside the store. resolved.json parsed leniently as JSON Value. - cache_clean_legacy / detect_legacy_layout: drop the pre-v2 github/db + github/checkouts dirs (idempotent). - parse_duration (30d/12h/90m/60s → ms), format_bytes (toFixed(1) parity), build_cache_{ls,gc}_model (JSON, legacy omitted when false). cli.rs wires cache ls/gc/clean with consola-matching stream routing (entry lines → stdout, headers → stderr) and exit-1 validation paths. 10 new tests, 232 total. Verified byte-identical to the TS build: 'cache ls --json' and 'cache gc --dry-run --json' over a fabricated ASK_HOME. --- crates/ask/src/cli.rs | 139 ++++++- crates/ask/src/store/cache.rs | 662 ++++++++++++++++++++++++++++++++++ crates/ask/src/store/mod.rs | 1 + 3 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 crates/ask/src/store/cache.rs diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index fab5fa1..3fd9dbc 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -216,7 +216,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Command::Fetch(args) => run_fetch_cmd(args), Command::Search(args) => run_search_cmd(args), Command::Skills(_) => Err(NotPorted::new("skills").into()), - Command::Cache(_) => Err(NotPorted::new("cache").into()), + Command::Cache(args) => run_cache_cmd(args), } } @@ -267,6 +267,143 @@ fn run_fetch_cmd(args: FetchArgs) -> anyhow::Result<()> { Ok(()) } +/// `ask cache {ls|gc|clean}` — inspect and prune the global ASK store. +fn run_cache_cmd(args: CacheArgs) -> anyhow::Result<()> { + use crate::store::cache::{ + build_cache_gc_model, build_cache_ls_model, cache_clean_legacy, cache_gc, cache_ls, + detect_legacy_layout, format_bytes, parse_duration, CacheGcOptions, CacheKind, + }; + use crate::store::resolve_ask_home; + + let ask_home = resolve_ask_home(); + + match args.command { + CacheCommand::Ls(a) => { + let filter = match a.kind.as_deref() { + Some(k) => match CacheKind::parse(k) { + Some(kind) => Some(kind), + None => { + eprintln!( + "Invalid --kind '{k}'. Must be one of: npm, github, web, llms-txt" + ); + std::process::exit(1); + } + }, + None => None, + }; + if a.json { + let model = build_cache_ls_model(&ask_home, filter); + println!("{}", serde_json::to_string_pretty(&model)?); + return Ok(()); + } + let entries = cache_ls(&ask_home, filter); + if entries.is_empty() { + eprintln!("No entries in store at {}", ask_home.display()); + return Ok(()); + } + eprintln!("Store: {}", ask_home.display()); + eprintln!( + "{} entr{}:\n", + entries.len(), + if entries.len() == 1 { "y" } else { "ies" } + ); + for e in &entries { + println!( + " {}/{} {}", + e.kind.as_str(), + e.key, + format_bytes(e.size_bytes) + ); + } + let total: u64 = entries.iter().map(|e| e.size_bytes).sum(); + eprintln!("\nTotal: {}", format_bytes(total)); + } + CacheCommand::Gc(a) => { + let scan_roots = std::env::var("ASK_GC_SCAN_ROOTS") + .ok() + .map(|v| v.split(':').map(std::path::PathBuf::from).collect()); + let older_than = match a.older_than.as_deref() { + Some(raw) => match parse_duration(raw) { + Some(ms) => Some(ms), + None => { + eprintln!( + "Invalid --older-than value '{raw}'. Use format like 30d, 12h, 90m, 60s." + ); + std::process::exit(1); + } + }, + None => None, + }; + let opts = CacheGcOptions { + dry_run: a.dry_run, + scan_roots, + older_than, + silent: a.json, + }; + if a.json { + let model = build_cache_gc_model(&ask_home, &opts); + println!("{}", serde_json::to_string_pretty(&model)?); + return Ok(()); + } + let result = cache_gc(&ask_home, &opts); + if result.removed.is_empty() { + eprintln!("Store is clean — no unreferenced entries."); + return Ok(()); + } + let n = result.removed.len(); + let plural = if n == 1 { "y" } else { "ies" }; + if a.dry_run { + eprintln!( + "Would remove {n} entr{plural} ({}):", + format_bytes(result.freed_bytes) + ); + for e in &result.removed { + println!( + " {}/{} {}", + e.kind.as_str(), + e.key, + format_bytes(e.size_bytes) + ); + } + } else { + eprintln!( + "Removed {n} entr{plural}, freed {}.", + format_bytes(result.freed_bytes) + ); + } + } + CacheCommand::Clean(a) => { + if !a.legacy { + eprintln!( + "ask cache clean requires a mode. Pass --legacy to remove pre-v2 github store dirs." + ); + std::process::exit(1); + } + if !detect_legacy_layout(&ask_home) { + eprintln!( + "No legacy github store detected at {}. Nothing to clean.", + ask_home.display() + ); + return Ok(()); + } + let removed = cache_clean_legacy(&ask_home); + if removed.is_empty() { + eprintln!("No legacy paths removed."); + return Ok(()); + } + eprintln!( + "Removed {} legacy path{}:", + removed.len(), + if removed.len() == 1 { "" } else { "s" } + ); + for p in &removed { + println!(" {}", p.display()); + } + } + } + Ok(()) +} + /// `ask search ` — delegate a semantic search to csp (optional). fn run_search_cmd(args: SearchArgs) -> anyhow::Result<()> { use crate::commands::resolve_csp::resolve_csp_default; diff --git a/crates/ask/src/store/cache.rs b/crates/ask/src/store/cache.rs new file mode 100644 index 0000000..adef4ac --- /dev/null +++ b/crates/ask/src/store/cache.rs @@ -0,0 +1,662 @@ +//! Global store inspection + garbage collection for `ask cache`. Rust port of +//! `store/cache.ts`. +//! +//! `cache_ls` enumerates every entry under `` (npm/web/llms-txt kind +//! dirs + the nested `github/////` layout, plus the +//! legacy `github/checkouts` layout tagged `legacy`). `cache_gc` removes entries +//! not referenced by any `.ask/resolved.json` under the scan roots, with an +//! optional age gate. `cache_clean_legacy` drops the pre-v2 github dirs. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::Serialize; + +use crate::store::assert_contained; + +/// One entry in the global store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheEntry { + pub kind: CacheKind, + pub key: String, + pub path: PathBuf, + pub size_bytes: u64, + /// True for the legacy `github/checkouts` layout — rendered with a + /// `(legacy)` tag and a `(legacy) ` key prefix. + pub legacy: bool, +} + +/// Store entry kind. Serializes to the wire values `npm`/`github`/`web`/`llms-txt`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum CacheKind { + Npm, + Github, + Web, + LlmsTxt, +} + +impl CacheKind { + /// The on-disk kind directory / wire string. + pub fn as_str(self) -> &'static str { + match self { + CacheKind::Npm => "npm", + CacheKind::Github => "github", + CacheKind::Web => "web", + CacheKind::LlmsTxt => "llms-txt", + } + } + + /// Parse a `--kind` filter value. + pub fn parse(s: &str) -> Option { + match s { + "npm" => Some(CacheKind::Npm), + "github" => Some(CacheKind::Github), + "web" => Some(CacheKind::Web), + "llms-txt" => Some(CacheKind::LlmsTxt), + _ => None, + } + } +} + +// ── JSON output models ───────────────────────────────────────────── + +/// A cache entry as serialized in `--json` output (matches `CacheLsEntrySchema`). +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct CacheLsEntry { + pub kind: CacheKind, + pub key: String, + pub path: String, + #[serde(rename = "sizeBytes")] + pub size_bytes: u64, + /// Emitted only when true (absent otherwise), matching the TS shape. + #[serde(skip_serializing_if = "Option::is_none")] + pub legacy: Option, +} + +impl CacheLsEntry { + fn from_entry(e: &CacheEntry) -> Self { + CacheLsEntry { + kind: e.kind, + key: e.key.clone(), + path: e.path.to_string_lossy().into_owned(), + size_bytes: e.size_bytes, + legacy: if e.legacy { Some(true) } else { None }, + } + } +} + +/// `ask cache ls --json` model (matches `CacheLsModelSchema`). +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct CacheLsModel { + #[serde(rename = "askHome")] + pub ask_home: String, + #[serde(rename = "totalBytes")] + pub total_bytes: u64, + pub entries: Vec, +} + +/// `ask cache gc --json` model (matches `CacheGcModelSchema`). +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct CacheGcModel { + #[serde(rename = "askHome")] + pub ask_home: String, + #[serde(rename = "dryRun")] + pub dry_run: bool, + #[serde(rename = "freedBytes")] + pub freed_bytes: u64, + pub removed: Vec, +} + +pub fn build_cache_ls_model(ask_home: &Path, filter: Option) -> CacheLsModel { + let entries = cache_ls(ask_home, filter); + let total_bytes = entries.iter().map(|e| e.size_bytes).sum(); + CacheLsModel { + ask_home: ask_home.to_string_lossy().into_owned(), + total_bytes, + entries: entries.iter().map(CacheLsEntry::from_entry).collect(), + } +} + +pub fn build_cache_gc_model(ask_home: &Path, options: &CacheGcOptions) -> CacheGcModel { + let mut opts = options.clone(); + opts.silent = true; + let result = cache_gc(ask_home, &opts); + CacheGcModel { + ask_home: ask_home.to_string_lossy().into_owned(), + dry_run: options.dry_run, + freed_bytes: result.freed_bytes, + removed: result + .removed + .iter() + .map(CacheLsEntry::from_entry) + .collect(), + } +} + +// ── cache_ls ─────────────────────────────────────────────────────── + +/// List all entries in the store at `ask_home`, optionally filtered by kind. +pub fn cache_ls(ask_home: &Path, filter: Option) -> Vec { + let mut entries = Vec::new(); + let kinds: Vec = match filter { + Some(k) => vec![k], + None => vec![ + CacheKind::Npm, + CacheKind::Github, + CacheKind::Web, + CacheKind::LlmsTxt, + ], + }; + + for kind in kinds { + if kind == CacheKind::Github { + collect_github(ask_home, &mut entries); + } else { + let kind_dir = ask_home.join(kind.as_str()); + if !kind_dir.exists() { + continue; + } + for subdir in safe_readdir(&kind_dir) { + let entry_path = kind_dir.join(&subdir); + if !safe_is_directory(&entry_path) { + continue; + } + let size = dir_size(&entry_path); + entries.push(CacheEntry { + kind, + key: subdir, + path: entry_path, + size_bytes: size, + legacy: false, + }); + } + } + } + + entries +} + +fn collect_github(ask_home: &Path, entries: &mut Vec) { + // New nested layout: github///// + let github_dir = ask_home.join("github"); + if github_dir.exists() { + for host in safe_readdir(&github_dir) { + // Skip legacy subdirs — handled separately below. + if host == "db" || host == "checkouts" { + continue; + } + let host_path = github_dir.join(&host); + if !safe_is_directory(&host_path) { + continue; + } + for owner in safe_readdir(&host_path) { + let owner_path = host_path.join(&owner); + if !safe_is_directory(&owner_path) { + continue; + } + for repo in safe_readdir(&owner_path) { + let repo_path = owner_path.join(&repo); + if !safe_is_directory(&repo_path) { + continue; + } + for tag in safe_readdir(&repo_path) { + let tag_path = repo_path.join(&tag); + if !safe_is_directory(&tag_path) { + continue; + } + let size = dir_size(&tag_path); + entries.push(CacheEntry { + kind: CacheKind::Github, + key: format!("{host}/{owner}/{repo}/{tag}"), + path: tag_path, + size_bytes: size, + legacy: false, + }); + } + } + } + } + } + + // Legacy layout: github/checkouts/__// + let legacy_checkout_dir = ask_home.join("github").join("checkouts"); + if legacy_checkout_dir.exists() { + for repo_dir in safe_readdir(&legacy_checkout_dir) { + let repo_path = legacy_checkout_dir.join(&repo_dir); + if !safe_is_directory(&repo_path) { + continue; + } + for reference in safe_readdir(&repo_path) { + let ref_path = repo_path.join(&reference); + if !safe_is_directory(&ref_path) { + continue; + } + let size = dir_size(&ref_path); + entries.push(CacheEntry { + kind: CacheKind::Github, + key: format!("(legacy) {repo_dir}/{reference}"), + path: ref_path, + size_bytes: size, + legacy: true, + }); + } + } + } +} + +// ── Legacy detection + cleanup ───────────────────────────────────── + +/// Pre-store-v2 github paths under ``. +pub fn legacy_layout_paths(ask_home: &Path) -> Vec { + vec![ + ask_home.join("github").join("db"), + ask_home.join("github").join("checkouts"), + ] +} + +/// True iff any legacy github path exists under ``. +pub fn detect_legacy_layout(ask_home: &Path) -> bool { + legacy_layout_paths(ask_home).iter().any(|p| p.exists()) +} + +/// Remove all legacy github store paths under ``. Idempotent. +pub fn cache_clean_legacy(ask_home: &Path) -> Vec { + let mut removed = Vec::new(); + for p in legacy_layout_paths(ask_home) { + if p.exists() { + let _ = std::fs::remove_dir_all(&p); + removed.push(p); + } + } + removed +} + +// ── parse_duration ───────────────────────────────────────────────── + +/// Parse `30d`/`12h`/`90m`/`60s` into milliseconds. `None` on invalid input. +pub fn parse_duration(input: &str) -> Option { + let s = input.trim(); + let idx = s.find(|c: char| !c.is_ascii_digit())?; + if idx == 0 { + return None; // no leading digits + } + let (num, rest) = s.split_at(idx); + let unit = rest.trim(); + let n: u64 = num.parse().ok()?; + match unit { + "s" => Some(n * 1000), + "m" => Some(n * 60 * 1000), + "h" => Some(n * 60 * 60 * 1000), + "d" => Some(n * 24 * 60 * 60 * 1000), + _ => None, + } +} + +// ── cache_gc ─────────────────────────────────────────────────────── + +/// Options for [`cache_gc`]. +#[derive(Debug, Clone, Default)] +pub struct CacheGcOptions { + pub dry_run: bool, + /// Roots to scan for `.ask/resolved.json`. `None` → `[$HOME]`. + pub scan_roots: Option>, + /// Age gate in milliseconds; entries newer than `now - older_than` are kept. + pub older_than: Option, + /// Suppress per-entry progress (set by the `--json` path). + pub silent: bool, +} + +/// Result of a [`cache_gc`] run. +#[derive(Debug, Default)] +pub struct CacheGcResult { + pub removed: Vec, + pub kept: Vec, + pub freed_bytes: u64, +} + +/// Remove store entries not referenced by any `.ask/resolved.json` under the +/// scan roots. With `older_than`, unreferenced entries newer than the cutoff are +/// still kept. +pub fn cache_gc(ask_home: &Path, options: &CacheGcOptions) -> CacheGcResult { + let scan_roots = options.scan_roots.clone().unwrap_or_else(|| { + std::env::var("HOME") + .ok() + .filter(|h| !h.is_empty()) + .map(|h| vec![PathBuf::from(h)]) + .unwrap_or_default() + }); + + let referenced = collect_referenced_store_paths(&scan_roots, ask_home); + let all_entries = cache_ls(ask_home, None); + + let mut result = CacheGcResult::default(); + let cutoff_ms = options.older_than.map(|ot| now_ms().saturating_sub(ot)); + + for entry in all_entries { + if referenced.contains(&entry.path) { + result.kept.push(entry); + continue; + } + if let Some(cutoff) = cutoff_ms { + match entry_mtime_ms(&entry.path) { + Some(mtime) if mtime > cutoff => { + result.kept.push(entry); + continue; + } + None => { + // Stat failure → keep to avoid accidental removal. + result.kept.push(entry); + continue; + } + _ => {} + } + } + if !options.dry_run && std::fs::remove_dir_all(&entry.path).is_err() { + result.kept.push(entry); + continue; + } + result.freed_bytes += entry.size_bytes; + result.removed.push(entry); + } + + result +} + +fn collect_referenced_store_paths(scan_roots: &[PathBuf], ask_home: &Path) -> BTreeSet { + let mut referenced = BTreeSet::new(); + for root in scan_roots { + if root.as_os_str().is_empty() || !root.exists() { + continue; + } + find_resolved_json_files(root, &mut referenced, ask_home, 0, 8); + } + referenced +} + +fn find_resolved_json_files( + dir: &Path, + referenced: &mut BTreeSet, + ask_home: &Path, + depth: usize, + max_depth: usize, +) { + if depth > max_depth { + return; + } + + let resolved_path = dir.join(".ask").join("resolved.json"); + if resolved_path.exists() { + if let Ok(text) = std::fs::read_to_string(&resolved_path) { + if let Ok(data) = serde_json::from_str::(&text) { + if let Some(entries) = data.get("entries").and_then(|e| e.as_object()) { + for entry in entries.values() { + if let Some(sp) = entry.get("storePath").and_then(|v| v.as_str()) { + // Only trust storePath values inside ask_home — a + // stale/malicious resolved.json cannot pin entries + // outside the store. + let candidate = PathBuf::from(sp); + if let Ok(abs) = assert_contained(ask_home, &candidate) { + referenced.insert(abs); + } + } + } + } + } + } + } + + let Ok(read) = std::fs::read_dir(dir) else { + return; + }; + for entry in read.flatten() { + // is_dir() is false for symlinks, so symlinked dirs are excluded — + // preventing cycle traps and scope escape. + let Ok(ft) = entry.file_type() else { continue }; + if !ft.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with('.') || name == "node_modules" { + continue; + } + find_resolved_json_files( + &dir.join(name.as_ref()), + referenced, + ask_home, + depth + 1, + max_depth, + ); + } +} + +// ── format_bytes ─────────────────────────────────────────────────── + +/// Human-readable byte count. Matches the TS `toFixed(1)` rendering. +pub fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = 1024.0 * 1024.0; + const GB: f64 = 1024.0 * 1024.0 * 1024.0; + let b = bytes as f64; + if bytes < 1024 { + format!("{bytes} B") + } else if b < MB { + format!("{:.1} KB", b / KB) + } else if b < GB { + format!("{:.1} MB", b / MB) + } else { + format!("{:.1} GB", b / GB) + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn entry_mtime_ms(p: &Path) -> Option { + let meta = std::fs::metadata(p).ok()?; + let mtime = meta.modified().ok()?; + mtime + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_millis() as u64) +} + +fn safe_is_directory(p: &Path) -> bool { + std::fs::metadata(p).map(|m| m.is_dir()).unwrap_or(false) +} + +fn safe_readdir(dir: &Path) -> Vec { + match std::fs::read_dir(dir) { + Ok(read) => read + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(), + Err(_) => Vec::new(), + } +} + +fn dir_size(dir: &Path) -> u64 { + let mut size = 0; + let Ok(read) = std::fs::read_dir(dir) else { + return 0; + }; + for entry in read.flatten() { + let full = entry.path(); + match entry.file_type() { + Ok(ft) if ft.is_dir() => size += dir_size(&full), + Ok(_) => { + if let Ok(meta) = std::fs::metadata(&full) { + size += meta.len(); + } + } + Err(_) => {} + } + } + size +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_duration_units() { + assert_eq!(parse_duration("60s"), Some(60_000)); + assert_eq!(parse_duration("90m"), Some(90 * 60_000)); + assert_eq!(parse_duration("12h"), Some(12 * 3_600_000)); + assert_eq!(parse_duration("30d"), Some(30 * 86_400_000)); + assert_eq!(parse_duration(" 5 d "), Some(5 * 86_400_000)); + assert_eq!(parse_duration("d"), None); + assert_eq!(parse_duration("10w"), None); + assert_eq!(parse_duration("abc"), None); + } + + #[test] + fn format_bytes_thresholds() { + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(1024), "1.0 KB"); + assert_eq!(format_bytes(1536), "1.5 KB"); + assert_eq!(format_bytes(1024 * 1024), "1.0 MB"); + assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB"); + } + + fn touch(path: &Path, contents: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); + } + + #[test] + fn cache_ls_walks_all_kinds_and_legacy() { + let home = tempfile::tempdir().unwrap(); + let ah = home.path(); + touch(&ah.join("npm/react@18.3.1/INDEX.md"), "x"); + touch( + &ah.join("github/github.com/facebook/react/v18.2.0/README.md"), + "hello", + ); + touch(&ah.join("web/abcd1234/page.md"), "web"); + touch(&ah.join("llms-txt/efgh5678/llms.md"), "llms"); + // Legacy layout. + touch(&ah.join("github/checkouts/owner__repo/v1.0.0/f.md"), "leg"); + + let entries = cache_ls(ah, None); + let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect(); + assert!(keys.contains(&"react@18.3.1")); + assert!(keys.contains(&"github.com/facebook/react/v18.2.0")); + assert!(keys.contains(&"abcd1234")); + assert!(keys.contains(&"efgh5678")); + assert!(keys + .iter() + .any(|k| k.starts_with("(legacy) owner__repo/v1.0.0"))); + // Legacy entry flagged. + assert!(entries.iter().find(|e| e.legacy).is_some()); + // Filter by kind. + let only_web = cache_ls(ah, Some(CacheKind::Web)); + assert_eq!(only_web.len(), 1); + assert_eq!(only_web[0].kind, CacheKind::Web); + } + + #[test] + fn detect_and_clean_legacy() { + let home = tempfile::tempdir().unwrap(); + let ah = home.path(); + assert!(!detect_legacy_layout(ah)); + touch(&ah.join("github/db/some-clone/HEAD"), "x"); + touch(&ah.join("github/checkouts/o__r/v1/f.md"), "y"); + assert!(detect_legacy_layout(ah)); + let removed = cache_clean_legacy(ah); + assert_eq!(removed.len(), 2); + assert!(!detect_legacy_layout(ah)); + // Idempotent. + assert!(cache_clean_legacy(ah).is_empty()); + } + + #[test] + fn gc_removes_unreferenced_keeps_referenced() { + let home = tempfile::tempdir().unwrap(); + let ah = home.path(); + let used = ah.join("github/github.com/o/used/v1.0.0"); + let unused = ah.join("github/github.com/o/unused/v1.0.0"); + touch(&used.join("f.md"), "used"); + touch(&unused.join("f.md"), "unused"); + + // A project whose resolved.json references the `used` entry. + let proj = tempfile::tempdir().unwrap(); + let resolved = serde_json::json!({ + "entries": { "used": { "storePath": used.to_string_lossy() } } + }); + touch( + &proj.path().join(".ask/resolved.json"), + &resolved.to_string(), + ); + + let opts = CacheGcOptions { + dry_run: false, + scan_roots: Some(vec![proj.path().to_path_buf()]), + older_than: None, + silent: true, + }; + let result = cache_gc(ah, &opts); + assert_eq!(result.removed.len(), 1); + assert!(result.removed[0].key.contains("unused")); + assert!(!unused.exists()); + assert!(used.exists()); + } + + #[test] + fn gc_dry_run_does_not_delete() { + let home = tempfile::tempdir().unwrap(); + let ah = home.path(); + let unused = ah.join("web/abcd"); + touch(&unused.join("f.md"), "x"); + let proj = tempfile::tempdir().unwrap(); + let opts = CacheGcOptions { + dry_run: true, + scan_roots: Some(vec![proj.path().to_path_buf()]), + older_than: None, + silent: true, + }; + let result = cache_gc(ah, &opts); + assert_eq!(result.removed.len(), 1); + assert!(unused.exists()); // not actually deleted + } + + #[test] + fn gc_age_gate_keeps_fresh_entries() { + let home = tempfile::tempdir().unwrap(); + let ah = home.path(); + let fresh = ah.join("web/fresh"); + touch(&fresh.join("f.md"), "x"); + let proj = tempfile::tempdir().unwrap(); + // older_than very large → cutoff far in the past → fresh entry kept. + let opts = CacheGcOptions { + dry_run: false, + scan_roots: Some(vec![proj.path().to_path_buf()]), + older_than: Some(365 * 86_400_000), + silent: true, + }; + let result = cache_gc(ah, &opts); + assert!(result.removed.is_empty()); + assert!(fresh.exists()); + } + + #[test] + fn ls_json_model_omits_legacy_when_false() { + let home = tempfile::tempdir().unwrap(); + let ah = home.path(); + touch(&ah.join("web/abcd/f.md"), "hello"); + let model = build_cache_ls_model(ah, None); + let json = serde_json::to_string(&model).unwrap(); + assert!(json.contains(r#""kind":"web""#)); + assert!(json.contains(r#""sizeBytes""#)); + assert!(!json.contains("legacy")); + } +} diff --git a/crates/ask/src/store/mod.rs b/crates/ask/src/store/mod.rs index 9783eac..d03dd86 100644 --- a/crates/ask/src/store/mod.rs +++ b/crates/ask/src/store/mod.rs @@ -4,6 +4,7 @@ //! The `github/db` and `github/checkouts` legacy layouts are intentionally NOT //! reintroduced (see CLAUDE.md); only cache cleanup references them by name. +pub mod cache; mod paths; pub use paths::{ From 3d3342e30f0be958bd6d5f80a69f022d55bc006c Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 05:52:40 +0900 Subject: [PATCH 32/44] feat(rust): port `ask add` command with offline-first docs-path discovery Port `commands/add.ts` + the bare-`ask add` interactive flow from `interactive.ts` to Rust, plus the `discovery/candidates.ts` offline probe it depends on. Non-interactive contract (the byte-parity surface): - `--docs-paths a,b,c` persists an object entry with a sanitized override - `--clear-docs-paths` downgrades an object entry back to a bare string - bare `owner/repo` normalizes to `github:owner/repo`; other prefix-less specs error as ambiguous - `sanitize_docs_path` / `posix_normalize` reject absolute paths and `..` escapes, mirroring `path.posix.normalize` Interactive flow (bare `ask add`, TTY-only) is ported with `dialoguer` for the real prompts but fully seamed (multiselect / text / is_tty / installer) so unit tests never need a terminal. `read_project_deps` and `check_registry_batch` are pure and tested directly. `gather_docs_candidates` reuses `ensure_checkout(no_fetch: true)` so a cold cache is a silent skip, never a clone. Validation: 247 unit tests pass; three new differential cases (add_docs_paths / add_clear / add_github_bare) confirm byte-identical ask.json + generated files vs the TS oracle, including the drift-prone object-entry serialization. --- Cargo.lock | 76 ++- Cargo.toml | 4 + crates/ask/Cargo.toml | 1 + crates/ask/src/cli.rs | 63 +- crates/ask/src/commands/add.rs | 859 +++++++++++++++++++++++++ crates/ask/src/commands/mod.rs | 1 + crates/ask/src/discovery/candidates.rs | 168 +++++ crates/ask/src/discovery/mod.rs | 6 + crates/ask/src/lib.rs | 1 + scripts/parity-diff.sh | 22 + 10 files changed, 1188 insertions(+), 13 deletions(-) create mode 100644 crates/ask/src/commands/add.rs create mode 100644 crates/ask/src/discovery/candidates.rs create mode 100644 crates/ask/src/discovery/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 30b8519..b230019 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,7 @@ version = "0.4.8" dependencies = [ "anyhow", "clap", + "dialoguer", "flate2", "regex", "semver", @@ -87,7 +88,7 @@ dependencies = [ "sha2", "tar", "tempfile", - "thiserror", + "thiserror 2.0.18", "time", "ureq", "url", @@ -183,6 +184,19 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "cookie" version = "0.18.1" @@ -246,6 +260,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "thiserror 1.0.69", +] + [[package]] name = "digest" version = "0.10.7" @@ -276,6 +301,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -787,6 +818,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -869,13 +906,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -941,6 +998,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -1064,6 +1127,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index cba218f..e9fc8a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,10 @@ flate2 = "1" url = "2" # HTTP client for registry / npm / github metadata (rustls, no OpenSSL system dep). ureq = { version = "3", features = ["json"] } +# Interactive prompts for `ask add` (bare) and the docs-path multiselect. TTY-only +# leaf — the non-interactive contract (`--docs-paths`, `--clear-docs-paths`) is +# what gets byte-parity tested; the prompt path is seamed out in unit tests. +dialoguer = { version = "0.11", default-features = false } # Single-binary release profile (small, fast-to-start native binary — the whole # point of the port over the Bun-compiled artifact). diff --git a/crates/ask/Cargo.toml b/crates/ask/Cargo.toml index e2c1b20..c0d3b36 100644 --- a/crates/ask/Cargo.toml +++ b/crates/ask/Cargo.toml @@ -41,3 +41,4 @@ url = { workspace = true } tar = { workspace = true } flate2 = { workspace = true } tempfile = { workspace = true } +dialoguer = { workspace = true } diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index 3fd9dbc..ba203ee 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -58,20 +58,15 @@ pub enum Command { #[derive(Debug, Args)] pub struct AddArgs { - /// Library name or full spec (e.g. next, @mastra/client-js, npm:react). - pub spec: String, - /// Comma-separated docs-path override (non-interactive). + /// Library spec (e.g. npm:next, github:vercel/next.js@v14.2.3). Omit for + /// interactive mode. + pub spec: Option, + /// Comma-separated relative docs paths; skips the interactive prompt. #[arg(long = "docs-paths")] pub docs_paths: Option, - /// Downgrade an object entry back to a bare-string entry. + /// Remove any persisted docsPaths override and restore default discovery. #[arg(long = "clear-docs-paths")] pub clear_docs_paths: bool, - /// Explicit source override (registry auto-detect is bypassed). - #[arg(short = 's', long = "source")] - pub source: Option, - /// Allow a mutable ref (main/master/latest/...) in ask.json. - #[arg(long = "allow-mutable-ref")] - pub allow_mutable_ref: bool, } #[derive(Debug, Args)] @@ -209,7 +204,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Ok(()) } Command::List(args) => run_list(args), - Command::Add(_) => Err(NotPorted::new("add").into()), + Command::Add(args) => run_add_cmd(args), Command::Remove(args) => run_remove_cmd(args), Command::Src(args) => run_src_cmd(args), Command::Docs(args) => run_docs_cmd(args), @@ -220,6 +215,52 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { } } +/// `ask add [spec]` — add a library to ask.json and materialize its docs. With +/// no spec, runs the interactive dependency scanner (TTY-only). Both paths +/// print collected messages and exit 1 on error, matching the TS `exit(1)`. +fn run_add_cmd(args: AddArgs) -> anyhow::Result<()> { + let client = crate::http::UreqClient::new(); + let project_dir = current_dir()?; + + let Some(spec) = args.spec else { + use crate::commands::add::{run_interactive_add, RunInteractiveDeps}; + let report = run_interactive_add(&client, &project_dir, &RunInteractiveDeps::default())?; + for line in &report.stdout { + println!("{line}"); + } + for line in &report.stderr { + eprintln!("{line}"); + } + if report.exit_code != 0 { + std::process::exit(report.exit_code); + } + return Ok(()); + }; + + use crate::commands::add::{run_add, RunAddDeps, RunAddOptions}; + let options = RunAddOptions { + project_dir, + spec, + docs_paths_arg: args.docs_paths, + clear_docs_paths: args.clear_docs_paths, + }; + match run_add(&client, &options, &RunAddDeps::default()) { + Ok(report) => { + for line in &report.stdout { + println!("{line}"); + } + for line in &report.stderr { + eprintln!("{line}"); + } + Ok(()) + } + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + } +} + /// `ask src ` — print the cached source path (lazy fetch on miss). /// Failures (NoCacheError, resolver errors) print to stderr and exit 1, /// matching the TS `exit(1)` contract rather than the generic exit-2 path. diff --git a/crates/ask/src/commands/add.rs b/crates/ask/src/commands/add.rs new file mode 100644 index 0000000..2d42c7a --- /dev/null +++ b/crates/ask/src/commands/add.rs @@ -0,0 +1,859 @@ +//! `ask add ` — add a library to `ask.json` and materialize its docs via +//! `run_install`. Rust port of `commands/add.ts` + the bare-`ask add` +//! interactive flow in `interactive.ts`. +//! +//! The real contract is the persisted `ask.json`, NOT the prompt UI. The +//! non-interactive path (`--docs-paths`, `--clear-docs-paths`, cold-cache bare +//! `add`) is what gets byte-parity tested against the TS oracle; the TTY +//! multiselect / text prompts are seamed out so unit tests never need a +//! terminal, and the production defaults use `dialoguer`. + +use std::collections::HashSet; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; + +use crate::ask_json::{entry_from_spec, AskJson}; +use crate::commands::ensure_checkout::EnsureCheckoutDeps; +use crate::discovery::candidates::{ + gather_docs_candidates, CandidateGatheringError, CandidateGroup, +}; +use crate::http::HttpClient; +use crate::install::{run_install, RunInstallOptions}; +use crate::io::{read_ask_json, write_ask_json}; +use crate::registry::api::fetch_registry_entry; +use crate::registry::detect_ecosystem; +use crate::spec::{parse_spec, ParsedSpec}; + +/// A single choice presented by a multiselect prompt. `value` is what the +/// selection resolves to (an absolute path, or a dependency name); `label` and +/// `hint` are display-only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptOption { + pub label: String, + pub value: String, + pub hint: String, +} + +/// Normalize a POSIX path the way Node's `path.posix.normalize` does: collapse +/// `.`/`//`, resolve `..` (leaving leading `..` for relative escapes), and +/// return `.` for an empty result. Used by [`sanitize_docs_path`]. +fn posix_normalize(input: &str) -> String { + let mut stack: Vec<&str> = Vec::new(); + for seg in input.split('/') { + match seg { + "" | "." => {} + ".." => { + if matches!(stack.last(), Some(&top) if top != "..") { + stack.pop(); + } else { + stack.push(".."); + } + } + other => stack.push(other), + } + } + if stack.is_empty() { + ".".to_string() + } else { + stack.join("/") + } +} + +/// Validate and canonicalize a user-supplied docs path. Returns the +/// POSIX-normalized relative path on success, or `None` when the input is +/// unsafe / empty. Rejects empty/whitespace, absolute paths, and `..` escapes. +/// Parity with `sanitizeDocsPath`. +pub fn sanitize_docs_path(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + // `Path::is_absolute` matches `path.isAbsolute` on POSIX (leading `/`). + if Path::new(trimmed).is_absolute() { + return None; + } + let normalized = posix_normalize(&trimmed.replace('\\', "/")); + if normalized.is_empty() { + return None; + } + if normalized == ".." || normalized.starts_with("../") { + return None; + } + Some(normalized) +} + +const AMBIGUOUS_SPEC_HINT: &str = "Ambiguous spec '{}'. Use:\n\ + \u{20} • npm: (e.g. npm:next, npm:@mastra/client-js)\n\ + \u{20} • github:/@ (e.g. github:vercel/next.js@v14.2.3)"; + +/// True when `input` is an `owner/repo` pair (exactly one slash, both sides +/// non-empty). Equivalent to the `^[^/]+/[^/]+$` regex without a regex. +fn is_owner_repo(input: &str) -> bool { + let parts: Vec<&str> = input.split('/').collect(); + parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() +} + +/// `ask add ` spec normalization: a bare `owner/repo` becomes +/// `github:owner/repo`; anything else without a `:` is ambiguous and errors. +/// Parity with `add.ts`'s `normalizeAddSpec`. +pub fn normalize_add_spec(input: &str) -> anyhow::Result { + if !input.contains(':') { + if is_owner_repo(input) { + return Ok(format!("github:{input}")); + } + return Err(anyhow::anyhow!(AMBIGUOUS_SPEC_HINT.replacen("{}", input, 1))); + } + Ok(input.to_string()) +} + +/// Interactive-mode spec normalization: assumes npm for bare names, treats +/// `@scope/pkg` as npm (not github), never errors. Parity with +/// `interactive.ts`'s `normalizeAddSpec`. +pub fn normalize_add_spec_interactive(input: &str) -> String { + if !input.contains(':') { + if input.starts_with('@') { + return format!("npm:{input}"); + } + if is_owner_repo(input) { + return format!("github:{input}"); + } + return format!("npm:{input}"); + } + input.to_string() +} + +/// Options for [`run_add`]. +#[derive(Debug, Clone)] +pub struct RunAddOptions { + pub project_dir: PathBuf, + pub spec: String, + /// Raw CSV from `--docs-paths`. When `Some`, skips the interactive prompt. + pub docs_paths_arg: Option, + /// `--clear-docs-paths` — drop any existing `docsPaths` override. + pub clear_docs_paths: bool, +} + +/// What [`run_add`] produced: user-facing messages, collected so the CLI can +/// print them (routing is not parity-critical — the harness checks `ask.json`). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct AddReport { + pub stdout: Vec, + pub stderr: Vec, +} + +type GatherFn<'a> = + dyn Fn(&str, &Path) -> Result, CandidateGatheringError> + 'a; +type PromptFn<'a> = dyn Fn(&str, &[PromptOption]) -> Vec + 'a; +type InstallerFn<'a> = dyn Fn(&Path, &[String]) -> anyhow::Result<()> + 'a; + +/// Test/production seams for [`run_add`]. All `None` → real defaults. +#[derive(Default)] +pub struct RunAddDeps<'a> { + pub gather: Option<&'a GatherFn<'a>>, + pub prompt: Option<&'a PromptFn<'a>>, + pub is_tty: Option<&'a (dyn Fn() -> bool + 'a)>, + pub installer: Option<&'a InstallerFn<'a>>, +} + +/// `ask add ` implementation. Persists the entry (bare string or object +/// with a `docsPaths` override per the three policies below) then runs the +/// lazy-first installer for the single spec. +pub fn run_add( + client: &dyn HttpClient, + options: &RunAddOptions, + deps: &RunAddDeps, +) -> anyhow::Result { + let default_gather = |spec: &str, dir: &Path| { + gather_docs_candidates(client, spec, dir, &EnsureCheckoutDeps::default()) + }; + let gather: &GatherFn = deps.gather.unwrap_or(&default_gather); + + let default_prompt: &PromptFn = &default_multiselect; + let prompt: &PromptFn = deps.prompt.unwrap_or(default_prompt); + + let default_is_tty = || std::io::stdout().is_terminal(); + let is_tty: &dyn Fn() -> bool = deps.is_tty.unwrap_or(&default_is_tty); + + let default_installer = |dir: &Path, specs: &[String]| -> anyhow::Result<()> { + run_install( + dir, + &RunInstallOptions { + only_specs: Some(specs.to_vec()), + }, + )?; + Ok(()) + }; + let installer: &InstallerFn = deps.installer.unwrap_or(&default_installer); + + let spec = normalize_add_spec(&options.spec)?; + + if let ParsedSpec::Unknown { .. } = parse_spec(&spec) { + anyhow::bail!("Invalid spec: {spec}"); + } + + let mut ask_json = read_ask_json(&options.project_dir)?.unwrap_or(AskJson { + libraries: Vec::new(), + }); + let existing_idx = ask_json.libraries.iter().position(|e| e.spec() == spec); + + let mut report = AddReport::default(); + let selected = resolve_selected_paths(&spec, options, gather, prompt, is_tty, &mut report); + + let entry = entry_from_spec(spec.clone(), selected.as_deref().unwrap_or(&[])); + if let Some(idx) = existing_idx { + ask_json.libraries[idx] = entry; + report.stdout.push(format!("Updated {spec} in ask.json")); + } else { + ask_json.libraries.push(entry); + report.stdout.push(format!("Added {spec} to ask.json")); + } + write_ask_json(&options.project_dir, &ask_json)?; + + installer(&options.project_dir, &[spec])?; + Ok(report) +} + +/// Strip `root` from `abs`, yielding the relative POSIX-ish tail (empty when +/// equal). Candidates are always descendants of their group root. +fn relative(root: &Path, abs: &Path) -> String { + abs.strip_prefix(root) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| abs.to_string_lossy().into_owned()) +} + +fn basename(root: &Path) -> String { + root.file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// Resolve which docs paths (if any) to persist. Returns `None` for the +/// canonical bare-string form; `Some(paths)` for an object override. +fn resolve_selected_paths( + spec: &str, + options: &RunAddOptions, + gather: &GatherFn, + prompt: &PromptFn, + is_tty: &dyn Fn() -> bool, + report: &mut AddReport, +) -> Option> { + // 1. `--clear-docs-paths` → force canonical string form (downgrade). + if options.clear_docs_paths { + return None; + } + + // 2. `--docs-paths a,b,c` → store the supplied CSV, no prompt. + if let Some(csv) = &options.docs_paths_arg { + let mut parsed = Vec::new(); + for raw in csv.split(',') { + match sanitize_docs_path(raw) { + Some(safe) => parsed.push(safe), + None => { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + report.stderr.push(format!( + "Ignoring unsafe docs-path entry {trimmed:?} (must be a relative path that stays inside its root)." + )); + } + } + } + } + return if parsed.is_empty() { + None + } else { + Some(parsed) + }; + } + + // 3. Probe candidates offline; prompt only on a TTY with a real choice. + let groups = match gather(spec, &options.project_dir) { + Ok(g) => g, + Err(err) => { + report.stderr.push(format!( + "Could not probe docs candidates for {spec}: {}. Recording the spec without a docs-path override.", + err.cause + )); + return None; + } + }; + + let all_candidates: Vec<(&CandidateGroup, &PathBuf)> = groups + .iter() + .flat_map(|g| g.paths.iter().map(move |p| (g, p))) + .collect(); + let all_are_root_only = groups + .iter() + .all(|g| g.paths.len() == 1 && g.paths[0] == g.root); + + if !is_tty() || all_candidates.len() <= 1 || all_are_root_only { + return None; + } + + let prompt_options: Vec = all_candidates + .iter() + .map(|(g, abs)| { + let rel = relative(&g.root, abs); + PromptOption { + label: if rel.is_empty() { ".".to_string() } else { rel }, + value: abs.to_string_lossy().into_owned(), + hint: basename(&g.root), + } + }) + .collect(); + + let picked = prompt( + &format!("Select docs paths to keep for {spec} (space to toggle, enter to confirm):"), + &prompt_options, + ); + if picked.is_empty() { + return None; + } + + let mut selected = Vec::new(); + for abs in &picked { + if let Some((g, abs_path)) = all_candidates + .iter() + .find(|(_, p)| p.to_string_lossy() == *abs) + { + // Root selection resolves to `.` so the min(1) override schema + // still accepts it and the read side re-resolves to the root. + let rel = relative(&g.root, abs_path); + let rel = if rel.is_empty() { ".".to_string() } else { rel }; + if let Some(safe) = sanitize_docs_path(&rel) { + selected.push(safe); + } + } + } + if selected.is_empty() { + None + } else { + Some(selected) + } +} + +// --------------------------------------------------------------------------- +// Interactive flow (bare `ask add`) — TTY-only, not byte-parity tested. +// --------------------------------------------------------------------------- + +/// Dependency names (from `package.json` deps + devDeps) not already declared +/// in `ask.json`, sorted. Parity with `readProjectDeps`. +pub fn read_project_deps( + package_json: &serde_json::Value, + existing_specs: &[String], +) -> Vec { + let mut names: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for key in ["dependencies", "devDependencies"] { + if let Some(obj) = package_json.get(key).and_then(|v| v.as_object()) { + for k in obj.keys() { + if seen.insert(k.clone()) { + names.push(k.clone()); + } + } + } + } + if names.is_empty() { + return Vec::new(); + } + let registered: HashSet = existing_specs + .iter() + .filter_map(|s| match parse_spec(s) { + ParsedSpec::Npm { pkg, .. } => Some(pkg), + _ => None, + }) + .collect(); + let mut out: Vec = names + .into_iter() + .filter(|n| !registered.contains(n)) + .collect(); + out.sort(); + out +} + +/// Which of `deps` have a registry entry. Parity with `checkRegistryBatch` +/// (sequential here — result set, not concurrency, is the contract). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RegistryCheckResult { + pub registered: Vec, + pub unregistered: Vec, +} + +pub fn check_registry_batch( + client: &dyn HttpClient, + ecosystem: &str, + deps: &[String], +) -> RegistryCheckResult { + let mut result = RegistryCheckResult::default(); + for name in deps { + if fetch_registry_entry(client, ecosystem, name).is_some() { + result.registered.push(name.clone()); + } else { + result.unregistered.push(name.clone()); + } + } + result +} + +/// Report for the interactive flow, including an exit code (TTY-required guard +/// exits 1, mirroring `process.exit(1)`). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct InteractiveReport { + pub stdout: Vec, + pub stderr: Vec, + pub exit_code: i32, +} + +type TextFn<'a> = dyn Fn(&str, &str) -> String + 'a; + +/// Seams for [`run_interactive_add`]. +#[derive(Default)] +pub struct RunInteractiveDeps<'a> { + pub is_tty: Option<&'a (dyn Fn() -> bool + 'a)>, + pub multiselect: Option<&'a PromptFn<'a>>, + pub text: Option<&'a TextFn<'a>>, + pub installer: Option<&'a InstallerFn<'a>>, +} + +/// Interactive add — scans `package.json` deps, checks them against the ASK +/// registry, and lets the user multiselect + free-type specs to add. +pub fn run_interactive_add( + client: &dyn HttpClient, + project_dir: &Path, + deps: &RunInteractiveDeps, +) -> anyhow::Result { + let default_is_tty = || std::io::stdout().is_terminal(); + let is_tty: &dyn Fn() -> bool = deps.is_tty.unwrap_or(&default_is_tty); + let default_multi: &PromptFn = &default_multiselect; + let multiselect: &PromptFn = deps.multiselect.unwrap_or(default_multi); + let default_text_fn: &TextFn = &default_text; + let text: &TextFn = deps.text.unwrap_or(default_text_fn); + let default_installer = |dir: &Path, specs: &[String]| -> anyhow::Result<()> { + run_install( + dir, + &RunInstallOptions { + only_specs: Some(specs.to_vec()), + }, + )?; + Ok(()) + }; + let installer: &InstallerFn = deps.installer.unwrap_or(&default_installer); + + let mut report = InteractiveReport::default(); + + if !is_tty() { + report.stderr.push( + "Interactive mode requires a TTY. Use `ask add ` to add a library non-interactively." + .to_string(), + ); + report.exit_code = 1; + return Ok(report); + } + + let ecosystem = detect_ecosystem(project_dir); + report + .stdout + .push(format!("Detected ecosystem: {ecosystem}")); + + let pkg_path = project_dir.join("package.json"); + if !pkg_path.exists() { + report + .stderr + .push("No package.json found. Cannot scan dependencies.".to_string()); + report + .stdout + .push("Use `ask add ` to add a library directly.".to_string()); + return Ok(report); + } + + let package_json: serde_json::Value = match std::fs::read_to_string(&pkg_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + { + Some(v) => v, + None => { + report + .stderr + .push("Failed to parse package.json. Check for syntax errors.".to_string()); + return Ok(report); + } + }; + + let mut ask_json = read_ask_json(project_dir)?.unwrap_or(AskJson { + libraries: Vec::new(), + }); + let existing_specs: Vec = ask_json + .libraries + .iter() + .map(|e| e.spec().to_string()) + .collect(); + let dep_names = read_project_deps(&package_json, &existing_specs); + + if dep_names.is_empty() { + report + .stdout + .push("All project dependencies are already registered in ask.json.".to_string()); + return Ok(report); + } + + report.stdout.push(format!( + "Checking {} dependencies against ASK registry...", + dep_names.len() + )); + let RegistryCheckResult { + registered, + unregistered, + } = check_registry_batch(client, ecosystem, &dep_names); + + let mut choices: Vec = Vec::new(); + for name in ®istered { + choices.push(PromptOption { + label: name.clone(), + value: name.clone(), + hint: "registry".to_string(), + }); + } + for name in &unregistered { + choices.push(PromptOption { + label: name.clone(), + value: name.clone(), + hint: "not in registry".to_string(), + }); + } + + if choices.is_empty() { + report + .stdout + .push("No new dependencies to add.".to_string()); + return Ok(report); + } + + report.stdout.push(format!( + "Found {} registered, {} unregistered in ASK registry.", + registered.len(), + unregistered.len() + )); + + let selected = multiselect( + "Select libraries to add (space to toggle, enter to confirm):", + &choices, + ); + + let placeholder = "npm:lodash, github:owner/repo@v1"; + let specs: Vec = if selected.is_empty() { + let manual = text( + "Enter a spec manually (or press enter to skip):", + placeholder, + ); + if manual.trim().is_empty() { + report.stdout.push("No libraries selected.".to_string()); + return Ok(report); + } + manual + .split(',') + .map(|s| normalize_add_spec_interactive(s.trim())) + .filter(|s| !s.is_empty()) + .collect() + } else { + // Selected names always come from package.json → npm ecosystem. + let mut specs: Vec = selected.iter().map(|n| format!("npm:{n}")).collect(); + let manual = text( + "Any additional specs to add manually? (press enter to skip):", + placeholder, + ); + if !manual.trim().is_empty() { + specs.extend( + manual + .split(',') + .map(|s| normalize_add_spec_interactive(s.trim())) + .filter(|s| !s.is_empty()), + ); + } + specs + }; + + add_specs(project_dir, &mut ask_json, &specs, installer, &mut report)?; + Ok(report) +} + +fn add_specs( + project_dir: &Path, + ask_json: &mut AskJson, + specs: &[String], + installer: &InstallerFn, + report: &mut InteractiveReport, +) -> anyhow::Result<()> { + let mut added: Vec = Vec::new(); + for spec in specs { + if let ParsedSpec::Unknown { .. } = parse_spec(spec) { + report.stderr.push(format!("Skipping invalid spec: {spec}")); + continue; + } + if ask_json.libraries.iter().any(|e| e.spec() == spec) { + report.stdout.push(format!("{spec} already in ask.json")); + continue; + } + ask_json.libraries.push(entry_from_spec(spec.clone(), &[])); + added.push(spec.clone()); + } + + if added.is_empty() { + report.stdout.push("No new libraries to add.".to_string()); + return Ok(()); + } + + write_ask_json(project_dir, ask_json)?; + let plural = if added.len() == 1 { "y" } else { "ies" }; + report.stdout.push(format!( + "Added {} librar{plural} to ask.json: {}", + added.len(), + added.join(", ") + )); + + installer(project_dir, &added)?; + Ok(()) +} + +/// Production multiselect: `dialoguer::MultiSelect`. On any I/O error (no TTY, +/// interrupted) returns an empty selection, matching the "nothing picked" path. +fn default_multiselect(msg: &str, opts: &[PromptOption]) -> Vec { + use dialoguer::MultiSelect; + let items: Vec = opts + .iter() + .map(|o| { + if o.hint.is_empty() { + o.label.clone() + } else { + format!("{} ({})", o.label, o.hint) + } + }) + .collect(); + match MultiSelect::new().with_prompt(msg).items(&items).interact() { + Ok(idxs) => idxs + .into_iter() + .filter_map(|i| opts.get(i).map(|o| o.value.clone())) + .collect(), + Err(_) => Vec::new(), + } +} + +/// Production text input: `dialoguer::Input`, empty allowed (enter to skip). +fn default_text(msg: &str, _placeholder: &str) -> String { + use dialoguer::Input; + Input::::new() + .with_prompt(msg) + .allow_empty(true) + .interact_text() + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + + #[test] + fn posix_normalize_matches_node() { + assert_eq!(posix_normalize("docs/./api"), "docs/api"); + assert_eq!(posix_normalize("docs//api"), "docs/api"); + assert_eq!(posix_normalize("a/b/.."), "a"); + assert_eq!(posix_normalize("a/../.."), ".."); + assert_eq!(posix_normalize("."), "."); + assert_eq!(posix_normalize("./docs"), "docs"); + } + + #[test] + fn sanitize_rejects_unsafe_accepts_relative() { + assert_eq!(sanitize_docs_path("docs"), Some("docs".to_string())); + assert_eq!( + sanitize_docs_path(" docs/api "), + Some("docs/api".to_string()) + ); + assert_eq!( + sanitize_docs_path("docs\\api"), + Some("docs/api".to_string()) + ); + assert_eq!(sanitize_docs_path("."), Some(".".to_string())); + assert_eq!(sanitize_docs_path(""), None); + assert_eq!(sanitize_docs_path(" "), None); + assert_eq!(sanitize_docs_path("/abs"), None); + assert_eq!(sanitize_docs_path(".."), None); + assert_eq!(sanitize_docs_path("../escape"), None); + assert_eq!(sanitize_docs_path("a/../../b"), None); + } + + #[test] + fn normalize_add_spec_owner_repo_and_ambiguous() { + assert_eq!( + normalize_add_spec("vercel/next.js").unwrap(), + "github:vercel/next.js" + ); + assert_eq!(normalize_add_spec("npm:next").unwrap(), "npm:next"); + assert!(normalize_add_spec("lodash").is_err()); + } + + #[test] + fn normalize_interactive_assumes_npm() { + assert_eq!(normalize_add_spec_interactive("lodash"), "npm:lodash"); + assert_eq!( + normalize_add_spec_interactive("@mastra/client-js"), + "npm:@mastra/client-js" + ); + assert_eq!( + normalize_add_spec_interactive("vercel/next.js"), + "github:vercel/next.js" + ); + assert_eq!( + normalize_add_spec_interactive("github:o/r@v1"), + "github:o/r@v1" + ); + } + + #[test] + fn read_project_deps_dedups_sorts_excludes_registered() { + let pkg = serde_json::json!({ + "dependencies": { "react": "18", "zod": "3" }, + "devDependencies": { "vite": "5", "react": "18" } + }); + let existing = vec!["npm:react".to_string()]; + assert_eq!(read_project_deps(&pkg, &existing), vec!["vite", "zod"]); + } + + #[test] + fn read_project_deps_empty() { + let pkg = serde_json::json!({}); + assert!(read_project_deps(&pkg, &[]).is_empty()); + } + + fn write_ask_json_raw(dir: &Path, body: &str) { + std::fs::write(dir.join("ask.json"), body).unwrap(); + } + + #[test] + fn run_add_clear_docs_paths_writes_bare_string() { + let proj = tempfile::tempdir().unwrap(); + write_ask_json_raw( + proj.path(), + r#"{"libraries":[{"spec":"npm:react","docsPaths":["docs"]}]}"#, + ); + let no_install = |_: &Path, _: &[String]| -> anyhow::Result<()> { Ok(()) }; + let deps = RunAddDeps { + installer: Some(&no_install), + ..Default::default() + }; + let opts = RunAddOptions { + project_dir: proj.path().to_path_buf(), + spec: "npm:react".into(), + docs_paths_arg: None, + clear_docs_paths: true, + }; + run_add(&MockClient::new(), &opts, &deps).unwrap(); + let written = std::fs::read_to_string(proj.path().join("ask.json")).unwrap(); + assert!(written.contains("\"npm:react\"")); + assert!(!written.contains("docsPaths")); + } + + #[test] + fn run_add_docs_paths_csv_persists_object_and_warns_unsafe() { + let proj = tempfile::tempdir().unwrap(); + let no_install = |_: &Path, _: &[String]| -> anyhow::Result<()> { Ok(()) }; + let deps = RunAddDeps { + installer: Some(&no_install), + ..Default::default() + }; + let opts = RunAddOptions { + project_dir: proj.path().to_path_buf(), + spec: "npm:react".into(), + docs_paths_arg: Some("docs, ../evil ,api".into()), + clear_docs_paths: false, + }; + let report = run_add(&MockClient::new(), &opts, &deps).unwrap(); + let written = std::fs::read_to_string(proj.path().join("ask.json")).unwrap(); + assert!(written.contains("docsPaths")); + assert!(written.contains("\"docs\"")); + assert!(written.contains("\"api\"")); + assert!(!written.contains("evil")); + assert!(report.stderr.iter().any(|m| m.contains("unsafe docs-path"))); + } + + #[test] + fn run_add_bare_owner_repo_normalizes_to_github() { + let proj = tempfile::tempdir().unwrap(); + let captured = std::cell::RefCell::new(Vec::new()); + let capture = |_: &Path, specs: &[String]| -> anyhow::Result<()> { + captured.borrow_mut().extend_from_slice(specs); + Ok(()) + }; + let deps = RunAddDeps { + installer: Some(&capture), + ..Default::default() + }; + let opts = RunAddOptions { + project_dir: proj.path().to_path_buf(), + spec: "vercel/next.js".into(), + docs_paths_arg: None, + clear_docs_paths: false, + }; + run_add(&MockClient::new(), &opts, &deps).unwrap(); + assert_eq!(captured.borrow().as_slice(), ["github:vercel/next.js"]); + let written = std::fs::read_to_string(proj.path().join("ask.json")).unwrap(); + assert!(written.contains("github:vercel/next.js")); + } + + #[test] + fn interactive_requires_tty() { + let proj = tempfile::tempdir().unwrap(); + let no_tty = || false; + let deps = RunInteractiveDeps { + is_tty: Some(&no_tty), + ..Default::default() + }; + let report = run_interactive_add(&MockClient::new(), proj.path(), &deps).unwrap(); + assert_eq!(report.exit_code, 1); + assert!(report.stderr[0].contains("requires a TTY")); + } + + #[test] + fn interactive_no_package_json() { + let proj = tempfile::tempdir().unwrap(); + let tty = || true; + let deps = RunInteractiveDeps { + is_tty: Some(&tty), + ..Default::default() + }; + let report = run_interactive_add(&MockClient::new(), proj.path(), &deps).unwrap(); + assert_eq!(report.exit_code, 0); + assert!(report.stderr.iter().any(|m| m.contains("No package.json"))); + } + + #[test] + fn interactive_selects_and_installs_npm_specs() { + let proj = tempfile::tempdir().unwrap(); + std::fs::write( + proj.path().join("package.json"), + r#"{"dependencies":{"lodash":"4"}}"#, + ) + .unwrap(); + let tty = || true; + // MockClient returns nothing → registry lookups miss → all unregistered. + let pick = |_: &str, opts: &[PromptOption]| -> Vec { + opts.iter().map(|o| o.value.clone()).collect() + }; + let no_text = |_: &str, _: &str| -> String { String::new() }; + let captured = std::cell::RefCell::new(Vec::new()); + let capture = |_: &Path, specs: &[String]| -> anyhow::Result<()> { + captured.borrow_mut().extend_from_slice(specs); + Ok(()) + }; + let deps = RunInteractiveDeps { + is_tty: Some(&tty), + multiselect: Some(&pick), + text: Some(&no_text), + installer: Some(&capture), + }; + let report = run_interactive_add(&MockClient::new(), proj.path(), &deps).unwrap(); + assert_eq!(report.exit_code, 0); + assert_eq!(captured.borrow().as_slice(), ["npm:lodash"]); + let written = std::fs::read_to_string(proj.path().join("ask.json")).unwrap(); + assert!(written.contains("npm:lodash")); + } +} diff --git a/crates/ask/src/commands/mod.rs b/crates/ask/src/commands/mod.rs index 7d52274..eb339a5 100644 --- a/crates/ask/src/commands/mod.rs +++ b/crates/ask/src/commands/mod.rs @@ -4,6 +4,7 @@ //! GitHub checkout in the global store on demand. Rust port of //! `packages/cli/src/commands/`. +pub mod add; pub mod docs; pub mod ensure_checkout; pub mod fetch; diff --git a/crates/ask/src/discovery/candidates.rs b/crates/ask/src/discovery/candidates.rs new file mode 100644 index 0000000..1639edc --- /dev/null +++ b/crates/ask/src/discovery/candidates.rs @@ -0,0 +1,168 @@ +//! Offline-first docs-candidate discovery for `ask add`. Rust port of +//! `discovery/candidates.ts`. +//! +//! Two locations are probed without ever triggering a network fetch: +//! 1. `node_modules//` for npm specs with a local install. +//! 2. The cached git checkout via `ensure_checkout(no_fetch: true)`. +//! +//! A cache miss is NOT an error — it yields fewer groups so the caller can +//! silently skip the docs-path prompt. Only genuinely unexpected failures +//! (malformed spec, bad ecosystem) surface as [`CandidateGatheringError`]. + +use std::path::{Path, PathBuf}; + +use crate::commands::ensure_checkout::{ + ensure_checkout, EnsureCheckoutDeps, EnsureCheckoutOptions, NoCacheError, +}; +use crate::commands::find_doc_paths::find_doc_like_paths; +use crate::http::HttpClient; +use crate::spec::{parse_spec, split_explicit_version, ParsedSpec}; + +/// A contiguous discovery region. `root` is the directory the paths are +/// relative to when persisted; `paths` are absolute candidate docs directories +/// (or the fallback `[root]` when no `/doc/i` subdirs exist). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CandidateGroup { + pub root: PathBuf, + pub paths: Vec, +} + +/// Raised only for unexpected failures (malformed spec, resolver error). A +/// missing cache or offline state is NOT an error — it yields zero groups. +/// Parity with the TS `CandidateGatheringError`, preserving the underlying +/// cause so `ask add` can print `err.cause.message`. +#[derive(Debug, thiserror::Error)] +#[error("could not gather docs candidates for {spec}: {cause}")] +pub struct CandidateGatheringError { + pub spec: String, + #[source] + pub cause: anyhow::Error, +} + +/// Gather candidate documentation directories for `spec` without triggering a +/// network fetch. Returns an empty vec when nothing is available — the caller +/// treats that as "skip the prompt, persist the spec without an override". +pub fn gather_docs_candidates( + client: &dyn HttpClient, + spec: &str, + project_dir: &Path, + deps: &EnsureCheckoutDeps, +) -> Result, CandidateGatheringError> { + let mut groups: Vec = Vec::new(); + + // 1. Direct node_modules probe for npm specs — no resolver hop. + let (spec_body, _) = split_explicit_version(spec); + if let ParsedSpec::Npm { pkg, .. } = parse_spec(spec_body) { + let nm_path = project_dir.join("node_modules").join(&pkg); + if nm_path.exists() { + let paths = find_doc_like_paths(&nm_path); + groups.push(CandidateGroup { + root: nm_path, + paths, + }); + } + } + + // 2. Cached checkout probe. `no_fetch: true` so a cache miss is a silent + // skip — we do NOT trigger a clone from `ask add`. + match ensure_checkout( + client, + &EnsureCheckoutOptions { + spec: spec.to_string(), + project_dir: project_dir.to_path_buf(), + no_fetch: true, + }, + deps, + ) { + Ok(result) => { + let paths = find_doc_like_paths(&result.checkout_dir); + groups.push(CandidateGroup { + root: result.checkout_dir, + paths, + }); + Ok(groups) + } + Err(err) => { + // Expected on first add of a never-fetched spec — skip the checkout + // group and proceed with whatever we collected. + if err.downcast_ref::().is_some() { + return Ok(groups); + } + // Malformed spec / bad ecosystem — a programmer-level problem. + // Wrap to preserve the spec for caller diagnostics. + Err(CandidateGatheringError { + spec: spec.to_string(), + cause: err, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + use crate::store::github_store_path; + + #[test] + fn npm_local_install_yields_node_modules_group() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + // Local install with a docs dir. + let pkg_docs = proj.path().join("node_modules/react/docs"); + std::fs::create_dir_all(&pkg_docs).unwrap(); + std::fs::write(pkg_docs.join("index.md"), "hi").unwrap(); + + // npm resolver hits the registry even with no_fetch (it needs owner/repo + // to compute the store path). Mock it so resolution succeeds, then the + // uncached checkout yields a NoCacheError → checkout group is skipped, + // leaving only the node_modules group. + let client = MockClient::new().with( + "https://registry.npmjs.org/react", + 200, + r#"{"dist-tags":{},"versions":{"18.3.1":{}},"repository":{"url":"https://github.com/facebook/react.git"}}"#, + ); + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + let groups = + gather_docs_candidates(&client, "npm:react@18.3.1", proj.path(), &deps).unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].root, proj.path().join("node_modules/react")); + assert!(groups[0].paths.iter().any(|p| p.ends_with("docs"))); + } + + #[test] + fn cached_checkout_yields_group() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = github_store_path(home.path(), "github.com", "o", "r", "v1.0.0").unwrap(); + std::fs::create_dir_all(dir.join("doc")).unwrap(); + + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + let groups = + gather_docs_candidates(&MockClient::new(), "github:o/r@v1.0.0", proj.path(), &deps) + .unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].root, dir); + } + + #[test] + fn no_cache_is_silent_skip_not_error() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let deps = EnsureCheckoutDeps { + ask_home: Some(home.path().to_path_buf()), + fetcher: None, + }; + // Uncached github spec, no node_modules → zero groups, no error. + let groups = + gather_docs_candidates(&MockClient::new(), "github:o/r@v9.9.9", proj.path(), &deps) + .unwrap(); + assert!(groups.is_empty()); + } +} diff --git a/crates/ask/src/discovery/mod.rs b/crates/ask/src/discovery/mod.rs new file mode 100644 index 0000000..7331cf8 --- /dev/null +++ b/crates/ask/src/discovery/mod.rs @@ -0,0 +1,6 @@ +//! Convention-based discovery helpers. On this branch only `candidates` is +//! wired into a command (`ask add`'s offline-first docs-path probe); the local +//! adapter chain (local-ask / local-intent / local-conventions) that `install` +//! consults upstream is not yet materialized here. + +pub mod candidates; diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index 2f66d9d..bd86081 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -10,6 +10,7 @@ pub mod agents; pub mod ask_json; pub mod cli; pub mod commands; +pub mod discovery; pub mod http; pub mod ignore_files; pub mod install; diff --git a/scripts/parity-diff.sh b/scripts/parity-diff.sh index e9ef27d..606d6ec 100755 --- a/scripts/parity-diff.sh +++ b/scripts/parity-diff.sh @@ -96,6 +96,23 @@ seed_empty() { seed_npm_and_github_rm() { seed_npm_and_github "$1"; } seed_github_only_rm() { seed_github_only "$1"; } +# `ask add` fixtures. A package.json + lockfile let the npm entry resolve its +# version offline (lazy-first install never downloads). The object-entry +# serialization ({ "spec", "docsPaths" }) is the drift-prone bit this exercises. +seed_add_docs_paths() { + printf '{\n "libraries": []\n}\n' >"$1/ask.json" + printf '{\n "name": "fixture",\n "dependencies": { "react": "^18.2.0" }\n}\n' >"$1/package.json" + printf '{\n "lockfileVersion": 3,\n "packages": {\n "node_modules/react": { "version": "18.3.1" }\n }\n}\n' >"$1/package-lock.json" +} +seed_add_clear() { + printf '{\n "libraries": [{ "spec": "npm:react", "docsPaths": ["docs"] }]\n}\n' >"$1/ask.json" + printf '{\n "name": "fixture",\n "dependencies": { "react": "^18.2.0" }\n}\n' >"$1/package.json" + printf '{\n "lockfileVersion": 3,\n "packages": {\n "node_modules/react": { "version": "18.3.1" }\n }\n}\n' >"$1/package-lock.json" +} +seed_add_github_bare() { + printf '{\n "libraries": []\n}\n' >"$1/ask.json" +} + # --- cases ----------------------------------------------------------------- echo "==> running parity cases" @@ -105,6 +122,11 @@ run_case empty install # Sequences: install then remove — exercises skill teardown + AGENTS.md regen. run_case npm_and_github_rm install "remove react" run_case github_only_rm install "remove next.js" +# `ask add` non-interactive contract: CSV override (object entry), downgrade, +# and bare owner/repo → github normalization. +run_case add_docs_paths "add npm:react --docs-paths docs,api" +run_case add_clear "add npm:react --clear-docs-paths" +run_case add_github_bare "add vercel/next.js@v15.0.3" if [[ "$fail" == 0 ]]; then echo "ALL PARITY CASES IDENTICAL" From 607bc4e13c6de324ad1cf7f8c5172e3c80ee7c38 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 06:06:28 +0900 Subject: [PATCH 33/44] feat(rust): port `ask skills` vendoring/symlink/lock subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the standalone agent-skills subsystem from `packages/cli/src/skills/` and `commands/skills/` — separate from the docs-skill generation in `skill.rs`. Completes the command surface: every `ask` subcommand is now backed by its Rust implementation (NotPorted is retired from cli.rs). Modules: - `skills/spec_key` — encode/decode the `.ask/skills//` dir name - `skills/agent_detect` — detect .claude/.cursor/.opencode/.codex markers - `skills/symlinks` — relative symlinks with lexical path.relative, owned- link teardown, conflict-unless-force - `skills/lock` — `.ask/skills-lock.json` (BTreeMap entries for deterministic ordering; camelCase field order matches JSON.stringify; atomic tmp+rename) - `skills/vendor` — staged copy into `.ask/skills//`, refresh-safe - `commands/find_skill_paths` — `/skill/i` walker (root always included) - `commands/skills` — list / install / remove orchestrators Install is fully seamed (checkout fetcher, agent picker, `installedAt` clock) so unit tests run offline with no TTY; production uses `dialoguer` for the >1-agent picker. `ask skills ` shorthand maps to `list`. Validation: 279 unit tests pass. A bespoke differential case (skills install --agent claude, shared ASK_HOME + pre-warmed checkout) confirms byte-identical vendored files, agent symlink (relative target), and skills-lock.json vs the TS oracle — the volatile `installedAt` is normalized before diffing. 9 differential cases all IDENTICAL. --- crates/ask/src/cli.rs | 145 ++++- crates/ask/src/commands/find_skill_paths.rs | 95 +++ crates/ask/src/commands/mod.rs | 2 + crates/ask/src/commands/skills.rs | 660 ++++++++++++++++++++ crates/ask/src/lib.rs | 1 + crates/ask/src/skills/agent_detect.rs | 122 ++++ crates/ask/src/skills/lock.rs | 191 ++++++ crates/ask/src/skills/mod.rs | 10 + crates/ask/src/skills/spec_key.rs | 115 ++++ crates/ask/src/skills/symlinks.rs | 209 +++++++ crates/ask/src/skills/vendor.rs | 151 +++++ scripts/parity-diff.sh | 37 ++ 12 files changed, 1723 insertions(+), 15 deletions(-) create mode 100644 crates/ask/src/commands/find_skill_paths.rs create mode 100644 crates/ask/src/commands/skills.rs create mode 100644 crates/ask/src/skills/agent_detect.rs create mode 100644 crates/ask/src/skills/lock.rs create mode 100644 crates/ask/src/skills/mod.rs create mode 100644 crates/ask/src/skills/spec_key.rs create mode 100644 crates/ask/src/skills/symlinks.rs create mode 100644 crates/ask/src/skills/vendor.rs diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index ba203ee..b4c626b 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -1,15 +1,12 @@ //! Command-line surface for `ask`, mirroring `packages/cli/src/index.ts`. //! //! The parser is the real, stable contract (`ask --help` lists every command); -//! command *bodies* are filled in per migration phase. Un-ported commands return -//! [`NotPorted`](crate::NotPorted) so the surface is honest about what works. +//! every command body is now backed by its ported implementation. use std::env::current_dir; use clap::{Args, Parser, Subcommand}; -use crate::NotPorted; - /// Agent Skills Kit — download version-specific library docs for AI coding agents. #[derive(Debug, Parser)] #[command( @@ -136,17 +133,56 @@ pub struct SearchArgs { #[derive(Debug, Args)] pub struct SkillsArgs { #[command(subcommand)] - pub command: SkillsCommand, + pub command: Option, + /// `ask skills ` shorthand for `ask skills list `. + #[arg(value_name = "SPEC")] + pub spec: Option, + /// Return cache hit only — exit 1 on cache miss (shorthand form). + #[arg(long = "no-fetch")] + pub no_fetch: bool, } #[derive(Debug, Subcommand)] pub enum SkillsCommand { - /// Install standalone skills. - Install, - /// Remove standalone skills. - Remove, - /// List installed standalone skills. - List, + /// Print candidate producer-side skill paths for a spec. + List(SkillsListArgs), + /// Vendor producer-side skills and symlink them into detected agents. + Install(SkillsInstallArgs), + /// Remove a previously-installed skill set by spec. + Remove(SkillsRemoveArgs), +} + +#[derive(Debug, Args)] +pub struct SkillsListArgs { + /// Library spec (e.g. react, npm:react@18.2.0, github:facebook/react@v18.2.0). + pub spec: String, + /// Return cache hit only — exit 1 on cache miss. + #[arg(long = "no-fetch")] + pub no_fetch: bool, +} + +#[derive(Debug, Args)] +pub struct SkillsInstallArgs { + /// Library spec (e.g. react, npm:react@18.2.0, github:facebook/react@v18.2.0). + pub spec: String, + /// Return cache hit only — exit 1 on cache miss. + #[arg(long = "no-fetch")] + pub no_fetch: bool, + /// Overwrite conflicting entries in agent skills dirs. + #[arg(long)] + pub force: bool, + /// Explicit agent targets (CSV): claude,cursor,opencode,codex. + #[arg(long)] + pub agent: Option, +} + +#[derive(Debug, Args)] +pub struct SkillsRemoveArgs { + /// Spec (same as used with install) or spec-key. + pub spec: String, + /// Silently succeed if the spec has no lock entry. + #[arg(long = "ignore-missing")] + pub ignore_missing: bool, } #[derive(Debug, Args)] @@ -195,8 +231,7 @@ pub struct CacheCleanArgs { pub legacy: bool, } -/// Dispatch a parsed [`Cli`] to its command. Ported commands run their real -/// logic; the rest still return [`NotPorted`] until their migration phase. +/// Dispatch a parsed [`Cli`] to its command implementation. pub fn run(cli: Cli) -> anyhow::Result<()> { match cli.command { Command::Install => { @@ -210,7 +245,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> { Command::Docs(args) => run_docs_cmd(args), Command::Fetch(args) => run_fetch_cmd(args), Command::Search(args) => run_search_cmd(args), - Command::Skills(_) => Err(NotPorted::new("skills").into()), + Command::Skills(args) => run_skills_cmd(args), Command::Cache(args) => run_cache_cmd(args), } } @@ -261,6 +296,81 @@ fn run_add_cmd(args: AddArgs) -> anyhow::Result<()> { } } +/// `ask skills {list|install|remove}` — surface and vendor producer-side agent +/// skills. `ask skills ` with no subcommand is a shorthand for `list`. +fn run_skills_cmd(args: SkillsArgs) -> anyhow::Result<()> { + use crate::commands::skills::{ + run_skills_install, run_skills_list, run_skills_remove, RunSkillsInstallOptions, + RunSkillsListOptions, RunSkillsRemoveOptions, SkillsReport, + }; + let client = crate::http::UreqClient::new(); + let project_dir = current_dir()?; + + let report: SkillsReport = match args.command { + None => { + // Bare `ask skills` with no spec is a no-op (matches citty's run()). + let Some(spec) = args.spec else { + return Ok(()); + }; + run_skills_list( + &client, + &RunSkillsListOptions { + spec, + project_dir, + no_fetch: args.no_fetch, + }, + &Default::default(), + ) + } + Some(SkillsCommand::List(a)) => run_skills_list( + &client, + &RunSkillsListOptions { + spec: a.spec, + project_dir, + no_fetch: a.no_fetch, + }, + &Default::default(), + ), + Some(SkillsCommand::Install(a)) => { + let agents = a.agent.as_deref().and_then(|s| { + let v: Vec = s + .split(',') + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + .collect(); + (!v.is_empty()).then_some(v) + }); + run_skills_install( + &client, + &RunSkillsInstallOptions { + spec: a.spec, + project_dir, + no_fetch: a.no_fetch, + force: a.force, + agents, + }, + &Default::default(), + )? + } + Some(SkillsCommand::Remove(a)) => run_skills_remove(&RunSkillsRemoveOptions { + spec: a.spec, + project_dir, + ignore_missing: a.ignore_missing, + })?, + }; + + for line in &report.stdout { + println!("{line}"); + } + for line in &report.stderr { + eprintln!("{line}"); + } + if report.exit_code != 0 { + std::process::exit(report.exit_code); + } + Ok(()) +} + /// `ask src ` — print the cached source path (lazy fetch on miss). /// Failures (NoCacheError, resolver errors) print to stderr and exit 1, /// matching the TS `exit(1)` contract rather than the generic exit-2 path. @@ -594,6 +704,11 @@ mod tests { Cli::try_parse_from(["ask", "list", "--json"]).unwrap(); Cli::try_parse_from(["ask", "fetch", "npm:next", "github:a/b", "-q"]).unwrap(); Cli::try_parse_from(["ask", "cache", "gc", "--dry-run", "--older-than", "30d"]).unwrap(); - Cli::try_parse_from(["ask", "skills", "list"]).unwrap(); + Cli::try_parse_from(["ask", "skills", "list", "react"]).unwrap(); + Cli::try_parse_from(["ask", "skills", "install", "react", "--agent", "claude"]).unwrap(); + Cli::try_parse_from(["ask", "skills", "remove", "react", "--ignore-missing"]).unwrap(); + // Shorthand: bare `ask skills ` and no-arg `ask skills` both parse. + Cli::try_parse_from(["ask", "skills", "react"]).unwrap(); + Cli::try_parse_from(["ask", "skills"]).unwrap(); } } diff --git a/crates/ask/src/commands/find_skill_paths.rs b/crates/ask/src/commands/find_skill_paths.rs new file mode 100644 index 0000000..6347ac4 --- /dev/null +++ b/crates/ask/src/commands/find_skill_paths.rs @@ -0,0 +1,95 @@ +//! Producer-side skill-directory discovery for `ask skills`. Rust port of +//! `commands/find-skill-paths.ts`. +//! +//! Mirrors [`find_doc_like_paths`](super::find_doc_paths::find_doc_like_paths) +//! with a `/skill/i` test instead of `/doc/i`, and two differences: the root is +//! ALWAYS included (index 0), and there is no `dist/docs` special case. + +use std::path::{Path, PathBuf}; + +/// Skip set — mirrors `find_doc_like_paths`. +const SKIP_DIRS: &[&str] = &[ + "node_modules", + ".git", + ".next", + ".nuxt", + "dist", + "build", + "coverage", +]; + +/// Maximum walk depth. Root counts as depth 0. +const MAX_DEPTH: usize = 4; + +/// Case-insensitive `/skill/i` test on a directory basename. +fn is_skill_dir(name: &str) -> bool { + name.to_ascii_lowercase().contains("skill") +} + +/// Walk `root` and return the root plus every nested directory whose basename +/// matches `/skill/i`, up to depth 4. Returns an empty vec when `root` does not +/// exist (no error). +pub fn find_skill_like_paths(root: &Path) -> Vec { + if !root.exists() { + return Vec::new(); + } + let mut results: Vec = vec![root.to_path_buf()]; + walk(root, 0, &mut results); + results +} + +fn walk(current_dir: &Path, depth: usize, out: &mut Vec) { + if depth >= MAX_DEPTH { + return; + } + let Ok(entries) = std::fs::read_dir(current_dir) else { + return; + }; + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if !ft.is_dir() { + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if SKIP_DIRS.contains(&name.as_ref()) || name.starts_with('.') { + continue; + } + let full = current_dir.join(name.as_ref()); + if is_skill_dir(&name) { + out.push(full.clone()); + } + walk(&full, depth + 1, out); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_root_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + assert!(find_skill_like_paths(&dir.path().join("nope")).is_empty()); + } + + #[test] + fn root_always_first_then_skill_subdirs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("skills/my-skill")).unwrap(); + std::fs::create_dir_all(dir.path().join("src")).unwrap(); + let paths = find_skill_like_paths(dir.path()); + assert_eq!(paths[0], dir.path().to_path_buf()); + assert!(paths.iter().any(|p| p.ends_with("skills"))); + assert!(!paths.iter().any(|p| p.ends_with("src"))); + } + + #[test] + fn skips_node_modules_and_dotdirs() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("node_modules/skills")).unwrap(); + std::fs::create_dir_all(dir.path().join(".hidden-skill")).unwrap(); + let paths = find_skill_like_paths(dir.path()); + assert_eq!(paths, vec![dir.path().to_path_buf()]); + } +} diff --git a/crates/ask/src/commands/mod.rs b/crates/ask/src/commands/mod.rs index eb339a5..e979a93 100644 --- a/crates/ask/src/commands/mod.rs +++ b/crates/ask/src/commands/mod.rs @@ -9,7 +9,9 @@ pub mod docs; pub mod ensure_checkout; pub mod fetch; pub mod find_doc_paths; +pub mod find_skill_paths; pub mod remove; pub mod resolve_csp; pub mod search; +pub mod skills; pub mod src; diff --git a/crates/ask/src/commands/skills.rs b/crates/ask/src/commands/skills.rs new file mode 100644 index 0000000..51c4a3e --- /dev/null +++ b/crates/ask/src/commands/skills.rs @@ -0,0 +1,660 @@ +//! `ask skills {list|install|remove}` — surface and vendor producer-side agent +//! skills. Rust port of `commands/skills/{list,install,remove}.ts`. +//! +//! `list` prints the fixed `/skills/` directory for each available source. +//! `install` vendors skill bundles into `.ask/skills//`, symlinks them +//! into detected agents, and records the lock. `remove` reverses an install +//! using the lock as the source of truth. The non-interactive contract +//! (`--agent`, `--no-fetch`, `--ignore-missing`) is what gets byte-parity +//! tested; the agent-picker prompt is a seamed TTY leaf. + +use std::path::{Path, PathBuf}; + +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +use crate::commands::ensure_checkout::{ + ensure_checkout, EnsureCheckoutDeps, EnsureCheckoutOptions, EnsureCheckoutResult, NoCacheError, +}; +use crate::commands::find_skill_paths::find_skill_like_paths; +use crate::http::HttpClient; +use crate::ignore_files::{manage_ignore_files, IgnoreMode}; +use crate::skills::agent_detect::{detect_agents, resolve_agent_names, AgentTarget}; +use crate::skills::lock::{ + read_lock, remove_entry, upsert_entry, write_lock_atomic, LockEntry, LockSkill, +}; +use crate::skills::spec_key::{encode_spec_key, SpecKeyInput}; +use crate::skills::symlinks::{link_skill, unlink_if_owned, LinkSkillOptions}; +use crate::skills::vendor::{remove_vendor_dir, vendor_skills, VENDOR_ROOT}; + +/// Common command output: collected lines plus the process exit code. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct SkillsReport { + pub stdout: Vec, + pub stderr: Vec, + pub exit_code: i32, +} + +/// True when `/skills/` exists and is a directory. +fn skills_dir_if_exists(root: &Path) -> Option { + let candidate = root.join("skills"); + if candidate.is_dir() { + Some(candidate) + } else { + None + } +} + +/// Resolve a spec to a checkout, converting the two error modes into a stderr +/// line + exit 1 (parity with the shared `catch` in every skills command). +fn resolve_or_report( + client: &dyn HttpClient, + spec: &str, + project_dir: &Path, + no_fetch: bool, + checkout: &EnsureCheckoutDeps, + report: &mut SkillsReport, +) -> Option { + match ensure_checkout( + client, + &EnsureCheckoutOptions { + spec: spec.to_string(), + project_dir: project_dir.to_path_buf(), + no_fetch, + }, + checkout, + ) { + Ok(r) => Some(r), + Err(err) => { + // NoCacheError and any other resolver error both print the message + // and exit 1 — the TS build distinguishes only for the type guard. + let msg = match err.downcast_ref::() { + Some(nc) => nc.to_string(), + None => err.to_string(), + }; + report.stderr.push(msg); + report.exit_code = 1; + None + } + } +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +/// Options for [`run_skills_list`]. +#[derive(Debug, Clone)] +pub struct RunSkillsListOptions { + pub spec: String, + pub project_dir: PathBuf, + pub no_fetch: bool, +} + +/// `ask skills [list] ` — print `/skills/` for node_modules and the +/// cached checkout. Exits 1 when neither exists. +pub fn run_skills_list( + client: &dyn HttpClient, + options: &RunSkillsListOptions, + checkout: &EnsureCheckoutDeps, +) -> SkillsReport { + let mut report = SkillsReport::default(); + let Some(result) = resolve_or_report( + client, + &options.spec, + &options.project_dir, + options.no_fetch, + checkout, + &mut report, + ) else { + return report; + }; + + let mut found: Vec = Vec::new(); + if let Some(pkg) = &result.npm_package_name { + let nm_path = options.project_dir.join("node_modules").join(pkg); + if let Some(s) = skills_dir_if_exists(&nm_path) { + found.push(s); + } + } + if let Some(s) = skills_dir_if_exists(&result.checkout_dir) { + found.push(s); + } + + if found.is_empty() { + report.stderr.push(format!( + "no skills/ directory found for {} — try 'ask src {}' for the checkout root", + options.spec, options.spec + )); + report.exit_code = 1; + return report; + } + + for p in found { + report.stdout.push(p.to_string_lossy().into_owned()); + } + report +} + +// --------------------------------------------------------------------------- +// install +// --------------------------------------------------------------------------- + +const SKILLS_PARENTS: &[&str] = &["skill", "skills"]; + +/// Options for [`run_skills_install`]. +#[derive(Debug, Clone)] +pub struct RunSkillsInstallOptions { + pub spec: String, + pub project_dir: PathBuf, + pub no_fetch: bool, + pub force: bool, + /// Explicit agent list (CSV from CLI). Overrides detection + prompt. + pub agents: Option>, +} + +/// Agent-picker seam: given the detected candidates, return the chosen subset. +type PickAgentsFn<'a> = dyn Fn(&[AgentTarget]) -> Vec + 'a; + +/// Seams for [`run_skills_install`]. +#[derive(Default)] +pub struct SkillsInstallDeps<'a> { + pub checkout: EnsureCheckoutDeps<'a>, + /// Picks agents when >1 detected and no explicit `--agent`. + pub pick_agents: Option<&'a PickAgentsFn<'a>>, + /// Clock seam for `installedAt` (default: real RFC3339 UTC). + pub now_iso: Option<&'a (dyn Fn() -> String + 'a)>, +} + +/// `ask skills install ` — resolve, vendor, pick agents, symlink, update +/// the lock, and patch ignore files. +pub fn run_skills_install( + client: &dyn HttpClient, + options: &RunSkillsInstallOptions, + deps: &SkillsInstallDeps, +) -> anyhow::Result { + let mut report = SkillsReport::default(); + + let Some(result) = resolve_or_report( + client, + &options.spec, + &options.project_dir, + options.no_fetch, + &deps.checkout, + &mut report, + ) else { + return Ok(report); + }; + + let sources = collect_skill_dirs(&options.project_dir, &result); + if sources.is_empty() { + report + .stderr + .push(format!("no skills/ directories found for {}", options.spec)); + report.exit_code = 1; + return Ok(report); + } + + // Agent selection: explicit flag > detect + prompt > detect-1 auto > error. + let agents: Vec = match &options.agents { + Some(names) if !names.is_empty() => { + match resolve_agent_names(&options.project_dir, names) { + Ok(a) => a, + Err(err) => { + report.stderr.push(err.to_string()); + report.exit_code = 1; + return Ok(report); + } + } + } + _ => { + let detected = detect_agents(&options.project_dir); + if detected.is_empty() { + report.stderr.push( + "no supported coding agent detected in this project (.claude/, .cursor/, .opencode/, .codex/). Pass --agent to force." + .to_string(), + ); + report.exit_code = 1; + return Ok(report); + } + let picked = if detected.len() == 1 { + detected + } else { + let default_pick = default_pick_agents; + let pick: &dyn Fn(&[AgentTarget]) -> Vec = + deps.pick_agents.unwrap_or(&default_pick); + pick(&detected) + }; + if picked.is_empty() { + report.stderr.push("no agents selected".to_string()); + report.exit_code = 1; + return Ok(report); + } + picked + } + }; + + // Encode the spec-key from the resolver result. + let ecosystem = if result.npm_package_name.is_some() { + "npm" + } else { + "github" + }; + let name = result + .npm_package_name + .clone() + .unwrap_or_else(|| format!("{}/{}", result.owner, result.repo)); + let spec_key = encode_spec_key(&SpecKeyInput { + ecosystem: ecosystem.to_string(), + name, + version: result.resolved_version.clone(), + })?; + + // Vendor + symlink. + let vendor = vendor_skills(&options.project_dir, &spec_key, &sources)?; + let agent_names: Vec = agents.iter().map(|a| a.name.clone()).collect(); + for skill in &vendor.skill_names { + let target_path = vendor.vendor_dir.join(skill); + for agent in &agents { + let link_path = agent.skills_dir.join(skill); + if let Err(err) = link_skill(&LinkSkillOptions { + link_path: &link_path, + target_path: &target_path, + force: options.force, + }) { + report.stderr.push(err.to_string()); + report.exit_code = 1; + return Ok(report); + } + } + } + + // Persist the lock. + let now = deps.now_iso.map(|f| f()).unwrap_or_else(default_now_iso); + let lock = read_lock(&options.project_dir)?; + let updated = upsert_entry( + &lock, + LockEntry { + spec: options.spec.clone(), + spec_key: spec_key.clone(), + skills: vendor + .skill_names + .iter() + .map(|n| LockSkill { + name: n.clone(), + agents: agent_names.clone(), + }) + .collect(), + installed_at: now, + }, + ); + write_lock_atomic(&options.project_dir, &updated)?; + + // Make sure .ask/skills/ and skills-lock.json are marked vendored. + manage_ignore_files(&options.project_dir, IgnoreMode::Install)?; + + report.stdout.push(format!( + "installed {} skill(s) for {} into {}", + vendor.skill_names.len(), + options.spec, + agent_names.join(", ") + )); + Ok(report) +} + +/// Gather the individual skill directories: for each `skill`/`skills` parent the +/// walker surfaced, every direct subdirectory (deduped). Parity with +/// `collectSkillDirs`. +fn collect_skill_dirs(project_dir: &Path, result: &EnsureCheckoutResult) -> Vec { + let mut parents: Vec = Vec::new(); + if let Some(pkg) = &result.npm_package_name { + let nm_path = project_dir.join("node_modules").join(pkg); + if nm_path.exists() { + parents.extend(find_skill_like_paths(&nm_path)); + } + } + parents.extend(find_skill_like_paths(&result.checkout_dir)); + + let mut skill_dirs: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for parent in &parents { + // Tight match: only exact `skill`/`skills` (case-insensitive) qualify as + // the producer-side "skills parent". + let basename = parent + .file_name() + .map(|s| s.to_string_lossy().to_ascii_lowercase()) + .unwrap_or_default(); + if !SKILLS_PARENTS.contains(&basename.as_str()) { + continue; + } + let Ok(entries) = std::fs::read_dir(parent) else { + continue; + }; + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + if !ft.is_dir() { + continue; + } + let child = parent.join(entry.file_name()); + if seen.insert(child.clone()) { + skill_dirs.push(child); + } + } + } + skill_dirs +} + +fn default_now_iso() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_default() +} + +/// Production agent picker — `dialoguer::MultiSelect` over agent labels. Empty +/// selection (or a non-TTY error) yields no agents, matching "nothing picked". +fn default_pick_agents(candidates: &[AgentTarget]) -> Vec { + use dialoguer::MultiSelect; + let labels: Vec<&str> = candidates.iter().map(|c| c.label.as_str()).collect(); + match MultiSelect::new() + .with_prompt("Install into which agents?") + .items(&labels) + .interact() + { + Ok(idxs) => idxs + .into_iter() + .filter_map(|i| candidates.get(i).cloned()) + .collect(), + Err(_) => Vec::new(), + } +} + +// --------------------------------------------------------------------------- +// remove +// --------------------------------------------------------------------------- + +/// Options for [`run_skills_remove`]. +#[derive(Debug, Clone)] +pub struct RunSkillsRemoveOptions { + /// Full user spec OR the spec-key directly. + pub spec: String, + pub project_dir: PathBuf, + pub ignore_missing: bool, +} + +/// `ask skills remove ` — reverse a prior install using the lock as the +/// source of truth. Never touches real dirs or foreign symlinks. +pub fn run_skills_remove(options: &RunSkillsRemoveOptions) -> anyhow::Result { + let mut report = SkillsReport::default(); + + let lock = read_lock(&options.project_dir)?; + let entry = lock + .entries + .values() + .find(|e| e.spec_key == options.spec || e.spec == options.spec) + .cloned(); + + let Some(entry) = entry else { + if options.ignore_missing { + report.stdout.push(format!( + "no lock entry for {} — nothing to do", + options.spec + )); + return Ok(report); + } + report.stderr.push(format!( + "no lock entry for {}. Pass --ignore-missing to silence.", + options.spec + )); + report.exit_code = 1; + return Ok(report); + }; + + let vendor_dir = options.project_dir.join(VENDOR_ROOT).join(&entry.spec_key); + let mut unlinked = 0usize; + for skill in &entry.skills { + let agents = match resolve_agent_names(&options.project_dir, &skill.agents) { + Ok(a) => a, + Err(err) => { + report.stderr.push(err.to_string()); + report.exit_code = 1; + return Ok(report); + } + }; + for agent in &agents { + let link_path = agent.skills_dir.join(&skill.name); + let target_path = vendor_dir.join(&skill.name); + if unlink_if_owned(&link_path, &target_path) { + unlinked += 1; + } + } + } + + remove_vendor_dir(&options.project_dir, &entry.spec_key)?; + write_lock_atomic(&options.project_dir, &remove_entry(&lock, &entry.spec_key))?; + + report.stdout.push(format!( + "removed {} symlink(s) and vendored copy for {}", + unlinked, options.spec + )); + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::mock::MockClient; + use crate::store::github_store_path; + + /// Pre-warm a checkout with a `skills//SKILL.md` bundle. + fn warmed_with_skill(skill: &str) -> (tempfile::TempDir, tempfile::TempDir, PathBuf) { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = github_store_path(home.path(), "github.com", "o", "r", "v1.0.0").unwrap(); + let bundle = dir.join("skills").join(skill); + std::fs::create_dir_all(&bundle).unwrap(); + std::fs::write(bundle.join("SKILL.md"), "# skill").unwrap(); + (home, proj, dir) + } + + fn checkout_deps(home: &Path) -> EnsureCheckoutDeps<'static> { + EnsureCheckoutDeps { + ask_home: Some(home.to_path_buf()), + fetcher: None, + } + } + + #[test] + fn list_prints_skills_dir() { + let (home, proj, dir) = warmed_with_skill("s"); + let report = run_skills_list( + &MockClient::new(), + &RunSkillsListOptions { + spec: "github:o/r@v1.0.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + }, + &checkout_deps(home.path()), + ); + assert_eq!(report.exit_code, 0); + assert_eq!( + report.stdout, + vec![dir.join("skills").to_string_lossy().into_owned()] + ); + } + + #[test] + fn list_no_skills_dir_exits_one() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = github_store_path(home.path(), "github.com", "o", "r", "v1.0.0").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); // no skills/ subdir + let report = run_skills_list( + &MockClient::new(), + &RunSkillsListOptions { + spec: "github:o/r@v1.0.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + }, + &checkout_deps(home.path()), + ); + assert_eq!(report.exit_code, 1); + assert!(report.stderr[0].contains("no skills/ directory")); + } + + #[test] + fn install_vendors_symlinks_and_locks() { + let (home, proj, _dir) = warmed_with_skill("my-skill"); + std::fs::create_dir_all(proj.path().join(".claude")).unwrap(); + let fixed_now = || "2026-07-04T00:00:00.000Z".to_string(); + let deps = SkillsInstallDeps { + checkout: checkout_deps(home.path()), + pick_agents: None, + now_iso: Some(&fixed_now), + }; + let report = run_skills_install( + &MockClient::new(), + &RunSkillsInstallOptions { + spec: "github:o/r@v1.0.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + force: false, + agents: Some(vec!["claude".into()]), + }, + &deps, + ) + .unwrap(); + assert_eq!(report.exit_code, 0); + // Vendored copy exists. + let vendored = proj + .path() + .join(".ask/skills/github__o__r__v1.0.0/my-skill/SKILL.md"); + assert!(vendored.exists()); + // Symlink into the agent dir, relative-encoded. + let link = proj.path().join(".claude/skills/my-skill"); + assert!(std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + // Lock records the entry. + let lock = read_lock(proj.path()).unwrap(); + assert!(lock.entries.contains_key("github__o__r__v1.0.0")); + } + + #[test] + fn install_no_agent_detected_exits_one() { + let (home, proj, _dir) = warmed_with_skill("s"); + let deps = SkillsInstallDeps { + checkout: checkout_deps(home.path()), + ..Default::default() + }; + let report = run_skills_install( + &MockClient::new(), + &RunSkillsInstallOptions { + spec: "github:o/r@v1.0.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + force: false, + agents: None, + }, + &deps, + ) + .unwrap(); + assert_eq!(report.exit_code, 1); + assert!(report.stderr[0].contains("no supported coding agent")); + } + + #[test] + fn install_no_skills_exits_one() { + let home = tempfile::tempdir().unwrap(); + let proj = tempfile::tempdir().unwrap(); + let dir = github_store_path(home.path(), "github.com", "o", "r", "v1.0.0").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(proj.path().join(".claude")).unwrap(); + let deps = SkillsInstallDeps { + checkout: checkout_deps(home.path()), + ..Default::default() + }; + let report = run_skills_install( + &MockClient::new(), + &RunSkillsInstallOptions { + spec: "github:o/r@v1.0.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + force: false, + agents: Some(vec!["claude".into()]), + }, + &deps, + ) + .unwrap(); + assert_eq!(report.exit_code, 1); + assert!(report.stderr[0].contains("no skills/ directories")); + } + + #[test] + fn install_then_remove_roundtrip() { + let (home, proj, _dir) = warmed_with_skill("my-skill"); + std::fs::create_dir_all(proj.path().join(".claude")).unwrap(); + let fixed_now = || "2026-07-04T00:00:00.000Z".to_string(); + run_skills_install( + &MockClient::new(), + &RunSkillsInstallOptions { + spec: "github:o/r@v1.0.0".into(), + project_dir: proj.path().to_path_buf(), + no_fetch: false, + force: false, + agents: Some(vec!["claude".into()]), + }, + &SkillsInstallDeps { + checkout: checkout_deps(home.path()), + pick_agents: None, + now_iso: Some(&fixed_now), + }, + ) + .unwrap(); + + let report = run_skills_remove(&RunSkillsRemoveOptions { + spec: "github:o/r@v1.0.0".into(), + project_dir: proj.path().to_path_buf(), + ignore_missing: false, + }) + .unwrap(); + assert_eq!(report.exit_code, 0); + assert_eq!( + report.stdout[0], + "removed 1 symlink(s) and vendored copy for github:o/r@v1.0.0" + ); + // Symlink and vendor gone; lock entry cleared. + assert!(std::fs::symlink_metadata(proj.path().join(".claude/skills/my-skill")).is_err()); + assert!(!proj + .path() + .join(".ask/skills/github__o__r__v1.0.0") + .exists()); + assert!(read_lock(proj.path()).unwrap().entries.is_empty()); + } + + #[test] + fn remove_missing_without_flag_exits_one() { + let proj = tempfile::tempdir().unwrap(); + let report = run_skills_remove(&RunSkillsRemoveOptions { + spec: "npm:nope".into(), + project_dir: proj.path().to_path_buf(), + ignore_missing: false, + }) + .unwrap(); + assert_eq!(report.exit_code, 1); + assert!(report.stderr[0].contains("no lock entry")); + } + + #[test] + fn remove_missing_with_flag_is_ok() { + let proj = tempfile::tempdir().unwrap(); + let report = run_skills_remove(&RunSkillsRemoveOptions { + spec: "npm:nope".into(), + project_dir: proj.path().to_path_buf(), + ignore_missing: true, + }) + .unwrap(); + assert_eq!(report.exit_code, 0); + assert!(report.stdout[0].contains("nothing to do")); + } +} diff --git a/crates/ask/src/lib.rs b/crates/ask/src/lib.rs index bd86081..f73ce7c 100644 --- a/crates/ask/src/lib.rs +++ b/crates/ask/src/lib.rs @@ -22,6 +22,7 @@ pub mod registry; pub mod resolved; pub mod resolvers; pub mod skill; +pub mod skills; pub mod sources; pub mod spec; pub mod storage; diff --git a/crates/ask/src/skills/agent_detect.rs b/crates/ask/src/skills/agent_detect.rs new file mode 100644 index 0000000..b819b50 --- /dev/null +++ b/crates/ask/src/skills/agent_detect.rs @@ -0,0 +1,122 @@ +//! Detect which coding agents a project targets, by the presence of their +//! marker directory. Rust port of `skills/agent-detect.ts`. + +use std::path::{Path, PathBuf}; + +/// A resolved agent install target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentTarget { + /// Stable identifier used in the lock file, e.g. `claude`. + pub name: String, + /// Human label shown in prompts, e.g. `Claude Code`. + pub label: String, + /// Marker directory whose presence enables this agent (absolute). + pub marker_dir: PathBuf, + /// Absolute path where `skills/` symlinks are created. + pub skills_dir: PathBuf, +} + +/// Static agent registry: (name, label, marker dir, skills-dir relative path). +const AGENTS: &[(&str, &str, &str, &str)] = &[ + ("claude", "Claude Code", ".claude", ".claude/skills"), + ("cursor", "Cursor", ".cursor", ".cursor/skills"), + ("opencode", "OpenCode", ".opencode", ".opencode/skills"), + ("codex", "Codex", ".codex", ".codex/skills"), +]; + +fn target_for( + project_dir: &Path, + name: &str, + label: &str, + marker: &str, + skills_rel: &str, +) -> AgentTarget { + AgentTarget { + name: name.to_string(), + label: label.to_string(), + marker_dir: project_dir.join(marker), + skills_dir: project_dir.join(skills_rel), + } +} + +/// Every supported agent whose marker directory is present under `project_dir`. +/// `AGENTS.md` alone is NOT an agent (it is a cross-agent convention file). +pub fn detect_agents(project_dir: &Path) -> Vec { + AGENTS + .iter() + .filter(|(_, _, marker, _)| project_dir.join(marker).exists()) + .map(|(name, label, marker, skills_rel)| { + target_for(project_dir, name, label, marker, skills_rel) + }) + .collect() +} + +/// Resolve a user-supplied `--agent` list into targets WITHOUT requiring the +/// marker dir to exist. Unknown names error (loud failure over silently +/// installing into an unintended location). Parity with `resolveAgentNames`. +pub fn resolve_agent_names( + project_dir: &Path, + names: &[String], +) -> anyhow::Result> { + names + .iter() + .map(|name| { + AGENTS + .iter() + .find(|(n, _, _, _)| *n == name) + .map(|(n, label, marker, skills_rel)| { + target_for(project_dir, n, label, marker, skills_rel) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "unknown agent '{name}'. Supported: {}", + supported_agents().join(", ") + ) + }) + }) + .collect() +} + +/// The supported agent identifiers, in registry order. +pub fn supported_agents() -> Vec<&'static str> { + AGENTS.iter().map(|(n, _, _, _)| *n).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_only_present_markers() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + std::fs::create_dir_all(dir.path().join(".cursor")).unwrap(); + let found = detect_agents(dir.path()); + let names: Vec<&str> = found.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, vec!["claude", "cursor"]); + assert_eq!(found[0].skills_dir, dir.path().join(".claude/skills")); + } + + #[test] + fn resolve_names_does_not_require_marker() { + let dir = tempfile::tempdir().unwrap(); + let got = resolve_agent_names(dir.path(), &["codex".to_string()]).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].name, "codex"); + assert_eq!(got[0].skills_dir, dir.path().join(".codex/skills")); + } + + #[test] + fn resolve_unknown_name_errors() { + let dir = tempfile::tempdir().unwrap(); + assert!(resolve_agent_names(dir.path(), &["bogus".to_string()]).is_err()); + } + + #[test] + fn supported_agents_order() { + assert_eq!( + supported_agents(), + vec!["claude", "cursor", "opencode", "codex"] + ); + } +} diff --git a/crates/ask/src/skills/lock.rs b/crates/ask/src/skills/lock.rs new file mode 100644 index 0000000..37e46f3 --- /dev/null +++ b/crates/ask/src/skills/lock.rs @@ -0,0 +1,191 @@ +//! The `ask skills` install lock (`.ask/skills-lock.json`) — the source of +//! truth for `skills remove`. Rust port of `skills/lock.ts`. +//! +//! Entries are keyed by spec-key. Unlike the TS build (insertion order), the +//! Rust store keys entries in a `BTreeMap` for deterministic on-disk ordering, +//! matching `resolved.json`'s convention. The differential harness normalizes +//! the volatile `installedAt` timestamp before diffing. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const LOCK_FILENAME: &str = ".ask/skills-lock.json"; + +/// One installed skill: its name and the agents it was symlinked into. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockSkill { + /// Skill name — the basename of the source skill directory. + pub name: String, + /// Agents whose `/skills/` symlinks were installed. + pub agents: Vec, +} + +/// One lock entry per installed spec. Field order matches the TS object literal +/// so `serde_json::to_string_pretty` reproduces `JSON.stringify` byte-for-byte. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LockEntry { + /// Original user-facing spec, e.g. `npm:next@14.2.3`. + pub spec: String, + /// Filesystem-safe encoding (see `encode_spec_key`). + pub spec_key: String, + /// Skills installed for this entry. + pub skills: Vec, + /// ISO timestamp of the last install. + pub installed_at: String, +} + +/// The whole lock file: a fixed `version: 1` plus the spec-key → entry map. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LockFile { + pub version: u8, + pub entries: BTreeMap, +} + +impl Default for LockFile { + fn default() -> Self { + LockFile { + version: 1, + entries: BTreeMap::new(), + } + } +} + +pub fn lock_path(project_dir: &Path) -> PathBuf { + project_dir.join(LOCK_FILENAME) +} + +/// Read the lock, returning an empty `version: 1` lock when the file is absent. +/// Errors on a schema mismatch (non-object, or `version != 1`). Parity with +/// `readLock`. +pub fn read_lock(project_dir: &Path) -> anyhow::Result { + let p = lock_path(project_dir); + if !p.exists() { + return Ok(LockFile::default()); + } + let raw = std::fs::read_to_string(&p)?; + let value: serde_json::Value = serde_json::from_str(&raw)?; + let ok = value.get("version").and_then(|v| v.as_u64()) == Some(1) + && value.get("entries").map(|e| e.is_object()).unwrap_or(false); + if !ok { + anyhow::bail!("{LOCK_FILENAME}: schema mismatch"); + } + Ok(serde_json::from_value(value)?) +} + +/// Return a new lock with `entry` inserted/overwritten under its spec-key. +pub fn upsert_entry(lock: &LockFile, entry: LockEntry) -> LockFile { + let mut entries = lock.entries.clone(); + entries.insert(entry.spec_key.clone(), entry); + LockFile { + version: 1, + entries, + } +} + +/// Return a new lock with `spec_key` removed (or the lock unchanged if absent). +pub fn remove_entry(lock: &LockFile, spec_key: &str) -> LockFile { + let mut entries = lock.entries.clone(); + entries.remove(spec_key); + LockFile { + version: 1, + entries, + } +} + +/// Serialize (`JSON.stringify(lock, null, 2)\n`) to a `.tmp` neighbour then +/// rename into place. The `.ask/` parent is created on demand. Parity with +/// `writeLockAtomic`. +pub fn write_lock_atomic(project_dir: &Path, lock: &LockFile) -> anyhow::Result<()> { + let target = lock_path(project_dir); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = target.with_extension("json.tmp"); + let body = format!("{}\n", serde_json::to_string_pretty(lock)?); + std::fs::write(&tmp, body)?; + std::fs::rename(&tmp, &target)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(spec: &str, spec_key: &str) -> LockEntry { + LockEntry { + spec: spec.to_string(), + spec_key: spec_key.to_string(), + skills: vec![LockSkill { + name: "s".to_string(), + agents: vec!["claude".to_string()], + }], + installed_at: "2026-07-04T00:00:00.000Z".to_string(), + } + } + + #[test] + fn missing_lock_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let lock = read_lock(dir.path()).unwrap(); + assert_eq!(lock.version, 1); + assert!(lock.entries.is_empty()); + } + + #[test] + fn write_read_roundtrip_and_camelcase_keys() { + let dir = tempfile::tempdir().unwrap(); + let lock = upsert_entry( + &LockFile::default(), + entry("npm:react@18", "npm__react__18"), + ); + write_lock_atomic(dir.path(), &lock).unwrap(); + let body = std::fs::read_to_string(dir.path().join(LOCK_FILENAME)).unwrap(); + assert!(body.contains("\"specKey\"")); + assert!(body.contains("\"installedAt\"")); + assert!(body.ends_with("}\n")); + let back = read_lock(dir.path()).unwrap(); + assert_eq!(back, lock); + } + + #[test] + fn field_order_matches_ts_literal() { + let lock = upsert_entry( + &LockFile::default(), + entry("npm:react@18", "npm__react__18"), + ); + let body = serde_json::to_string_pretty(&lock).unwrap(); + let spec = body.find("\"spec\"").unwrap(); + let spec_key = body.find("\"specKey\"").unwrap(); + let skills = body.find("\"skills\"").unwrap(); + let installed = body.find("\"installedAt\"").unwrap(); + assert!(spec < spec_key && spec_key < skills && skills < installed); + } + + #[test] + fn upsert_and_remove() { + let lock = upsert_entry(&LockFile::default(), entry("a", "ka")); + let lock = upsert_entry(&lock, entry("b", "kb")); + assert_eq!(lock.entries.len(), 2); + let lock = remove_entry(&lock, "ka"); + assert_eq!(lock.entries.len(), 1); + assert!(lock.entries.contains_key("kb")); + // Removing an absent key is a no-op. + let same = remove_entry(&lock, "nope"); + assert_eq!(same.entries.len(), 1); + } + + #[test] + fn schema_mismatch_errors() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".ask")).unwrap(); + std::fs::write( + dir.path().join(LOCK_FILENAME), + r#"{"version":2,"entries":{}}"#, + ) + .unwrap(); + assert!(read_lock(dir.path()).is_err()); + } +} diff --git a/crates/ask/src/skills/mod.rs b/crates/ask/src/skills/mod.rs new file mode 100644 index 0000000..ee11b59 --- /dev/null +++ b/crates/ask/src/skills/mod.rs @@ -0,0 +1,10 @@ +//! Standalone agent-skills subsystem for `ask skills` — vendoring producer-side +//! skill directories into `.ask/skills/` and symlinking them into detected +//! coding-agent directories. Separate from the docs-skill generation in +//! `skill.rs`. Rust port of `packages/cli/src/skills/`. + +pub mod agent_detect; +pub mod lock; +pub mod spec_key; +pub mod symlinks; +pub mod vendor; diff --git a/crates/ask/src/skills/spec_key.rs b/crates/ask/src/skills/spec_key.rs new file mode 100644 index 0000000..76aa41e --- /dev/null +++ b/crates/ask/src/skills/spec_key.rs @@ -0,0 +1,115 @@ +//! Filesystem-safe encoding of a resolved library spec, used as the top-level +//! directory name under `.ask/skills//`. Rust port of +//! `skills/spec-key.ts`. +//! +//! Only `/` and `:` — the structural separators that collide with path syntax +//! — are rewritten to `__`. `@` is kept as-is so scoped npm packages stay +//! human-readable. +//! +//! ```text +//! { npm, next, 14.2.3 } → npm__next__14.2.3 +//! { npm, @vercel/ai, 5.0.0 } → npm__@vercel__ai__5.0.0 +//! { github, vercel/ai, v5.0.0} → github__vercel__ai__v5.0.0 +//! ``` + +/// A decoded spec-key: ecosystem prefix, package name (or `owner/repo`), and +/// resolved version / git ref. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpecKeyInput { + pub ecosystem: String, + pub name: String, + pub version: String, +} + +/// Rewrite the two structural separators (`/` and `:`) to `__`. Errors on an +/// empty part (parity with `encodePart`'s throw). +fn encode_part(value: &str) -> anyhow::Result { + if value.is_empty() { + anyhow::bail!("spec-key part must be non-empty"); + } + Ok(value.replace(['/', ':'], "__")) +} + +/// Encode a [`SpecKeyInput`] into `ecosystem__name__version` with separators +/// rewritten. Parity with `encodeSpecKey`. +pub fn encode_spec_key(input: &SpecKeyInput) -> anyhow::Result { + Ok([ + encode_part(&input.ecosystem)?, + encode_part(&input.name)?, + encode_part(&input.version)?, + ] + .join("__")) +} + +/// Reverse of [`encode_spec_key`]. Splits on `__`; the canonical layout is +/// `[ecosystem, …name parts, version]` (≥3 segments). Name parts re-join with +/// `/`. Parity with `decodeSpecKey`. +pub fn decode_spec_key(key: &str) -> anyhow::Result { + let segments: Vec<&str> = key.split("__").collect(); + if segments.len() < 3 { + anyhow::bail!("malformed spec-key (needs at least 3 segments): {key}"); + } + let ecosystem = segments[0]; + let version = *segments.last().unwrap(); + let name = segments[1..segments.len() - 1].join("/"); + if ecosystem.is_empty() || name.is_empty() || version.is_empty() { + anyhow::bail!("malformed spec-key (empty segment): {key}"); + } + Ok(SpecKeyInput { + ecosystem: ecosystem.to_string(), + name, + version: version.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn k(e: &str, n: &str, v: &str) -> SpecKeyInput { + SpecKeyInput { + ecosystem: e.to_string(), + name: n.to_string(), + version: v.to_string(), + } + } + + #[test] + fn encode_examples() { + assert_eq!( + encode_spec_key(&k("npm", "next", "14.2.3")).unwrap(), + "npm__next__14.2.3" + ); + assert_eq!( + encode_spec_key(&k("npm", "@vercel/ai", "5.0.0")).unwrap(), + "npm__@vercel__ai__5.0.0" + ); + assert_eq!( + encode_spec_key(&k("github", "vercel/ai", "v5.0.0")).unwrap(), + "github__vercel__ai__v5.0.0" + ); + } + + #[test] + fn encode_rejects_empty_part() { + assert!(encode_spec_key(&k("", "n", "v")).is_err()); + } + + #[test] + fn decode_roundtrips_scoped_and_github() { + assert_eq!( + decode_spec_key("npm__next__14.2.3").unwrap(), + k("npm", "next", "14.2.3") + ); + // Scoped npm: name parts re-join with `/`. + assert_eq!( + decode_spec_key("github__vercel__ai__v5.0.0").unwrap(), + k("github", "vercel/ai", "v5.0.0") + ); + } + + #[test] + fn decode_rejects_short_key() { + assert!(decode_spec_key("npm__next").is_err()); + } +} diff --git a/crates/ask/src/skills/symlinks.rs b/crates/ask/src/skills/symlinks.rs new file mode 100644 index 0000000..55b8abd --- /dev/null +++ b/crates/ask/src/skills/symlinks.rs @@ -0,0 +1,209 @@ +//! Create and remove the relative symlinks that point an agent's +//! `skills/` at a vendored skill directory. Rust port of +//! `skills/symlinks.ts`. + +use std::path::{Component, Path, PathBuf}; + +/// Options for [`link_skill`]. +pub struct LinkSkillOptions<'a> { + pub link_path: &'a Path, + pub target_path: &'a Path, + pub force: bool, +} + +/// Raised when a link path is occupied by a conflicting entry and `force` is +/// unset. Parity with `SymlinkConflictError`. +#[derive(Debug, thiserror::Error)] +#[error("{link_path}: {reason}. Re-run with --force to overwrite.")] +pub struct SymlinkConflictError { + pub link_path: String, + pub reason: String, +} + +/// Lexical `path.relative(from, to)` over already-absolute, clean paths: shared +/// prefix, then `..` per remaining `from` segment plus the `to` tail. No +/// symlink resolution — matches Node's behaviour on normalized absolute inputs. +pub fn relative_path(from: &Path, to: &Path) -> PathBuf { + let from_comps: Vec = from.components().collect(); + let to_comps: Vec = to.components().collect(); + let mut i = 0; + while i < from_comps.len() && i < to_comps.len() && from_comps[i] == to_comps[i] { + i += 1; + } + let mut result = PathBuf::new(); + for _ in i..from_comps.len() { + result.push(".."); + } + for c in &to_comps[i..] { + result.push(c.as_os_str()); + } + result +} + +fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link) + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_dir(target, link) + } +} + +/// Create a relative symlink at `link_path` → `target_path`, creating the +/// parent dir on demand. A pre-existing identical symlink is a no-op; a +/// differing symlink or a real file/dir errors unless `force`. Parity with +/// `linkSkill`. +pub fn link_skill(opts: &LinkSkillOptions) -> anyhow::Result<()> { + if let Some(parent) = opts.link_path.parent() { + std::fs::create_dir_all(parent)?; + } + let rel_target = relative_path( + opts.link_path.parent().unwrap_or_else(|| Path::new("")), + opts.target_path, + ); + + if let Ok(meta) = std::fs::symlink_metadata(opts.link_path) { + if meta.file_type().is_symlink() { + let current = std::fs::read_link(opts.link_path)?; + if current == rel_target { + return Ok(()); // identical — no-op + } + if !opts.force { + return Err(SymlinkConflictError { + link_path: opts.link_path.to_string_lossy().into_owned(), + reason: format!( + "symlink points to '{}', expected '{}'", + current.to_string_lossy(), + rel_target.to_string_lossy() + ), + } + .into()); + } + std::fs::remove_file(opts.link_path)?; + } else { + if !opts.force { + return Err(SymlinkConflictError { + link_path: opts.link_path.to_string_lossy().into_owned(), + reason: "a non-symlink entry already exists".to_string(), + } + .into()); + } + if meta.file_type().is_dir() { + std::fs::remove_dir_all(opts.link_path)?; + } else { + std::fs::remove_file(opts.link_path)?; + } + } + } + + create_symlink(&rel_target, opts.link_path)?; + Ok(()) +} + +/// Remove `link_path` iff it is a symlink whose target matches `expected_target` +/// (relative-encoded). Protects user-authored skills of the same name. Returns +/// true when a link was removed. Parity with `unlinkIfOwned`. +pub fn unlink_if_owned(link_path: &Path, expected_target: &Path) -> bool { + let Ok(meta) = std::fs::symlink_metadata(link_path) else { + return false; + }; + if !meta.file_type().is_symlink() { + return false; + } + let rel_expected = relative_path( + link_path.parent().unwrap_or_else(|| Path::new("")), + expected_target, + ); + let Ok(current) = std::fs::read_link(link_path) else { + return false; + }; + if current != rel_expected { + return false; + } + std::fs::remove_file(link_path).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_path_cross_tree() { + let from = Path::new("/p/.claude/skills"); + let to = Path::new("/p/.ask/skills/key/my-skill"); + assert_eq!( + relative_path(from, to), + PathBuf::from("../../.ask/skills/key/my-skill") + ); + } + + #[test] + fn link_creates_relative_symlink_and_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join(".ask/skills/key/s"); + std::fs::create_dir_all(&target).unwrap(); + let link = dir.path().join(".claude/skills/s"); + let opts = LinkSkillOptions { + link_path: &link, + target_path: &target, + force: false, + }; + link_skill(&opts).unwrap(); + let read = std::fs::read_link(&link).unwrap(); + assert_eq!(read, relative_path(link.parent().unwrap(), &target)); + // Second call with identical target is a no-op (no error). + link_skill(&opts).unwrap(); + } + + #[test] + fn link_conflict_without_force_errors() { + let dir = tempfile::tempdir().unwrap(); + let link = dir.path().join(".claude/skills/s"); + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + std::fs::write(&link, "real file").unwrap(); + let target = dir.path().join(".ask/skills/key/s"); + std::fs::create_dir_all(&target).unwrap(); + let opts = LinkSkillOptions { + link_path: &link, + target_path: &target, + force: false, + }; + assert!(link_skill(&opts).is_err()); + // With force it succeeds. + let forced = LinkSkillOptions { + link_path: &link, + target_path: &target, + force: true, + }; + link_skill(&forced).unwrap(); + assert!(std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + fn unlink_only_owned_links() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join(".ask/skills/key/s"); + std::fs::create_dir_all(&target).unwrap(); + let link = dir.path().join(".claude/skills/s"); + link_skill(&LinkSkillOptions { + link_path: &link, + target_path: &target, + force: false, + }) + .unwrap(); + // Wrong expected target → not removed. + assert!(!unlink_if_owned( + &link, + &dir.path().join(".ask/skills/other/s") + )); + assert!(std::fs::symlink_metadata(&link).is_ok()); + // Correct target → removed. + assert!(unlink_if_owned(&link, &target)); + assert!(std::fs::symlink_metadata(&link).is_err()); + } +} diff --git a/crates/ask/src/skills/vendor.rs b/crates/ask/src/skills/vendor.rs new file mode 100644 index 0000000..f20377a --- /dev/null +++ b/crates/ask/src/skills/vendor.rs @@ -0,0 +1,151 @@ +//! Vendor producer-side skill directories into `.ask/skills//`. Rust +//! port of `skills/vendor.ts`. +//! +//! Refresh-safe: contents are staged under a sibling `..tmp` dir and +//! renamed into place, so a re-install replaces the entry atomically and a +//! mid-copy crash cannot leave a half-populated vendor directory. + +use std::path::{Path, PathBuf}; + +pub const VENDOR_ROOT: &str = ".ask/skills"; + +/// Result of [`vendor_skills`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VendorResult { + /// Absolute path to `.ask/skills//`. + pub vendor_dir: PathBuf, + /// Skill basenames that were copied in. + pub skill_names: Vec, +} + +/// Recursively copy `src` into `dst`, creating `dst` and parents on demand. +/// Follows the Node `fs.cpSync(recursive)` default (regular file contents; no +/// verbatim-symlink preservation — producer skills are plain trees). +fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ft = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ft.is_dir() { + copy_dir_recursive(&from, &to)?; + } else { + // Symlinks are dereferenced by std::fs::copy, matching Node's default. + std::fs::copy(&from, &to)?; + } + } + Ok(()) +} + +/// Copy each source skill directory into `.ask/skills///`. +/// Non-directory / missing sources are skipped. On a basename collision the +/// later copy wins (caller dedups). Parity with `vendorSkills`. +pub fn vendor_skills( + project_dir: &Path, + spec_key: &str, + sources: &[PathBuf], +) -> anyhow::Result { + let root = project_dir.join(VENDOR_ROOT); + let vendor_dir = root.join(spec_key); + std::fs::create_dir_all(&root)?; + + let staging = root.join(format!(".{spec_key}.tmp")); + if staging.exists() { + std::fs::remove_dir_all(&staging)?; + } + std::fs::create_dir_all(&staging)?; + + let mut skill_names: Vec = Vec::new(); + for source in sources { + if !source.is_dir() { + continue; + } + let Some(name) = source.file_name() else { + continue; + }; + let target = staging.join(name); + copy_dir_recursive(source, &target)?; + skill_names.push(name.to_string_lossy().into_owned()); + } + + if vendor_dir.exists() { + std::fs::remove_dir_all(&vendor_dir)?; + } + std::fs::rename(&staging, &vendor_dir)?; + + Ok(VendorResult { + vendor_dir, + skill_names, + }) +} + +/// Remove `.ask/skills//` if present. Parity with `removeVendorDir`. +pub fn remove_vendor_dir(project_dir: &Path, spec_key: &str) -> anyhow::Result<()> { + let vendor_dir = project_dir.join(VENDOR_ROOT).join(spec_key); + if vendor_dir.exists() { + std::fs::remove_dir_all(&vendor_dir)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_skill(root: &Path, name: &str) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("SKILL.md"), format!("# {name}")).unwrap(); + dir + } + + #[test] + fn vendors_sources_and_reports_names() { + let proj = tempfile::tempdir().unwrap(); + let srcroot = tempfile::tempdir().unwrap(); + let a = make_skill(srcroot.path(), "a"); + let b = make_skill(srcroot.path(), "b"); + let res = vendor_skills(proj.path(), "npm__x__1", &[a, b]).unwrap(); + assert_eq!(res.skill_names, vec!["a", "b"]); + assert!(res.vendor_dir.ends_with(".ask/skills/npm__x__1")); + assert!(res.vendor_dir.join("a/SKILL.md").exists()); + assert!(res.vendor_dir.join("b/SKILL.md").exists()); + } + + #[test] + fn reinstall_replaces_stale_contents() { + let proj = tempfile::tempdir().unwrap(); + let srcroot = tempfile::tempdir().unwrap(); + let a = make_skill(srcroot.path(), "a"); + vendor_skills(proj.path(), "k", std::slice::from_ref(&a)).unwrap(); + // Second install with a different source — old "a" must be gone. + let c = make_skill(srcroot.path(), "c"); + let res = vendor_skills(proj.path(), "k", &[c]).unwrap(); + assert_eq!(res.skill_names, vec!["c"]); + assert!(!res.vendor_dir.join("a").exists()); + assert!(res.vendor_dir.join("c/SKILL.md").exists()); + } + + #[test] + fn skips_missing_and_non_dir_sources() { + let proj = tempfile::tempdir().unwrap(); + let srcroot = tempfile::tempdir().unwrap(); + let a = make_skill(srcroot.path(), "a"); + let missing = srcroot.path().join("nope"); + let res = vendor_skills(proj.path(), "k", &[a, missing]).unwrap(); + assert_eq!(res.skill_names, vec!["a"]); + } + + #[test] + fn remove_vendor_dir_is_idempotent() { + let proj = tempfile::tempdir().unwrap(); + let srcroot = tempfile::tempdir().unwrap(); + let a = make_skill(srcroot.path(), "a"); + vendor_skills(proj.path(), "k", &[a]).unwrap(); + remove_vendor_dir(proj.path(), "k").unwrap(); + assert!(!proj.path().join(".ask/skills/k").exists()); + // Second removal is a no-op. + remove_vendor_dir(proj.path(), "k").unwrap(); + } +} diff --git a/scripts/parity-diff.sh b/scripts/parity-diff.sh index 606d6ec..22d6915 100755 --- a/scripts/parity-diff.sh +++ b/scripts/parity-diff.sh @@ -128,6 +128,43 @@ run_case add_docs_paths "add npm:react --docs-paths docs,api" run_case add_clear "add npm:react --clear-docs-paths" run_case add_github_bare "add vercel/next.js@v15.0.3" +# `ask skills install` — needs a bespoke harness (not run_case) for three +# reasons the advisor flagged: (1) a pre-warmed checkout under a shared ASK_HOME +# so ensureCheckout is a cache hit with no network; (2) skills-lock.json embeds +# a volatile `installedAt` timestamp that must be normalized before diffing; +# (3) the agent symlink's relative target is compared structurally by `diff -r` +# (both sides create the identical relative link, so it collapses to equal). +skills_install_parity() { + local name="skills_install" + local ts="$WORK/$name-ts" rs="$WORK/$name-rs" home="$WORK/$name-home" + # Shared store: install only READS the checkout, so both CLIs can point at it. + local checkout="$home/github/github.com/o/r/v1.0.0" + mkdir -p "$ts" "$rs" "$checkout/skills/my-skill" + printf '# my-skill\n\nProducer-side skill.\n' >"$checkout/skills/my-skill/SKILL.md" + local d + for d in "$ts" "$rs"; do + printf '{\n "libraries": []\n}\n' >"$d/ask.json" + mkdir -p "$d/.claude" + printf 'node_modules\n' >"$d/.gitignore" + done + ( cd "$ts" && env -i PATH="$PATH" HOME="$HOME" ASK_HOME="$home" NO_COLOR=1 node "$TS_CLI" skills install github:o/r@v1.0.0 --agent claude >/dev/null 2>&1 ) || true + ( cd "$rs" && env -i PATH="$PATH" HOME="$HOME" ASK_HOME="$home" NO_COLOR=1 "$RS_CLI" skills install github:o/r@v1.0.0 --agent claude >/dev/null 2>&1 ) || true + for d in "$ts" "$rs"; do + if [[ -f "$d/.ask/skills-lock.json" ]]; then + sed -i.bak 's/"installedAt": "[^"]*"/"installedAt": "NORM"/' "$d/.ask/skills-lock.json" + rm -f "$d/.ask/skills-lock.json.bak" + fi + done + if diff -r "$ts" "$rs" >"$WORK/$name.diff" 2>&1; then + echo " ok $name (skills install --agent claude; lock installedAt normalized)" + else + echo " FAIL $name" + sed 's/^/ /' "$WORK/$name.diff" + fail=1 + fi +} +skills_install_parity + if [[ "$fail" == 0 ]]; then echo "ALL PARITY CASES IDENTICAL" else From 15485d56fc4884919f0c32a358d3239b1955f322 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 06:17:58 +0900 Subject: [PATCH 34/44] ci(release): cut over @pleaseai/ask + crates.io publish to Rust binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Bun-compiled TypeScript binary pipeline with the Rust binaries built by the reusable release-rust.yml, and ship @pleaseai/ask as the copy-over shim (per-platform packages + pinned wrapper) instead of the TS tarball. - build-binaries: delete the bun --compile job + its separate upload-release-assets job; call ./.github/workflows/release-rust.yml (workflow_call) with the CLI tag so it cross-compiles all six targets and uploads assets (+.sha256) to the GitHub release. Unix asset names are unchanged, so the Homebrew tap keeps working. - npm-publish: keep @pleaseai/ask-schema (TS) as-is; replace the TS @pleaseai/ask publish with the shim flow — gh release download the built binaries, run generate-platform-packages.mjs, publish every @pleaseai/ask- package FIRST (idempotent skip-guard), then the wrapper LAST so its pinned optionalDependencies already exist. - cargo-publish: new job calling ./.github/workflows/release-cargo.yml (dry_run: false) with CARGO_REGISTRY_TOKEN. - update-homebrew-formula: rewire needs to build-binaries; formula body unchanged (reads the same .sha256 release assets). The cut-over only fires on a real CLI release from main; committing it on the port branch triggers no publish. Validated with actionlint and a local generate-platform-packages.mjs smoke test. --- .github/workflows/release.yml | 173 +++++++++++++++++----------------- 1 file changed, 85 insertions(+), 88 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0877ef..7ffa3e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,9 +36,24 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json + # --------------------------------------------------------------------------- + # Rust binary build. Cross-compiles the `ask` binary from crates/ask for every + # supported target and, because a tag is passed, uploads the assets (+.sha256) + # to the CLI GitHub release. These assets are the source of truth for BOTH the + # npm platform packages and the Homebrew formula — the unix asset names are + # identical to what the old Bun pipeline emitted, so the tap keeps working. + # --------------------------------------------------------------------------- + build-binaries: + name: Build Rust binaries + needs: release-please + if: needs.release-please.outputs.cli_release_created == 'true' + uses: ./.github/workflows/release-rust.yml + with: + tag: ${{ needs.release-please.outputs.cli_tag_name }} + npm-publish: name: Publish to npm - needs: release-please + needs: [release-please, build-binaries] if: | always() && ( @@ -74,8 +89,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Build all - run: bun run build + # Only the schema package is still published from TypeScript; build just it. + - name: Build schema + run: bun run --cwd packages/schema build # Workaround: bun pm pack resolves workspace:* from bun.lock, not # package.json (oven-sh/bun#20477). release-please bumps versions in @@ -115,107 +131,88 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Publish @pleaseai/ask + # --------------------------------------------------------------------- + # @pleaseai/ask is now the Rust binary, shipped as a wrapper package with + # per-platform optionalDependencies (the copy-over shim). We download the + # binaries built above from the GitHub release, generate the platform + # packages, publish each platform package FIRST, then the wrapper LAST — + # the wrapper pins exact optionalDependency versions, so publishing it + # before its platform deps exist would make `npm i @pleaseai/ask` resolve + # to missing binaries. + # --------------------------------------------------------------------- + - name: Publish @pleaseai/ask (Rust binary shim) if: | github.event_name == 'workflow_dispatch' || needs.release-please.outputs.cli_release_created == 'true' - working-directory: packages/cli - run: | - VERSION=$(node -p "require('./package.json').version") - if npm view "@pleaseai/ask@$VERSION" version 2>/dev/null; then - echo "⚠ @pleaseai/ask@$VERSION already published, skipping" - else - bun pm pack - npm publish pleaseai-ask-*.tgz --provenance --access public - fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - # --------------------------------------------------------------------------- - # Homebrew distribution: build standalone binaries, attach them to the CLI - # GitHub release, and update the formula in pleaseai/homebrew-tap. - # --------------------------------------------------------------------------- - build-binaries: - name: Build standalone binaries - needs: release-please - if: needs.release-please.outputs.cli_release_created == 'true' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.3.11 - - - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 - with: - path: | - ~/.bun/install/cache - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} - restore-keys: ${{ runner.os }}-bun- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Build schema (workspace dependency) - run: bun run --cwd packages/schema build - - # bun cross-compiles to every target from a single host, so no OS matrix - # is needed. The version is injected as a literal via --define because the - # compiled binary has no package.json on disk to read at runtime. - - name: Build binaries + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail + + # Resolve the release tag: the release path passes it through from + # release-please; publish_only re-derives it from the current version. TAG="${{ needs.release-please.outputs.cli_tag_name }}" + if [ -z "$TAG" ]; then + TAG="ask-v$(node -p "require('./packages/cli/package.json').version")" + fi VERSION_NO_V="${TAG#ask-v}" - mkdir -p dist-bin - for target in darwin-x64 darwin-arm64 linux-x64 linux-arm64; do - echo "::group::build ask-${target}" - bun build packages/cli/src/cli.ts \ - --compile \ - --target="bun-${target}" \ - --define "__ASK_VERSION__=\"${VERSION_NO_V}\"" \ - --outfile "dist-bin/ask-${target}" - ( cd dist-bin && shasum -a 256 "ask-${target}" > "ask-${target}.sha256" ) - echo "::endgroup::" + echo "publishing @pleaseai/ask shim for $TAG (version $VERSION_NO_V)" + + # Pull the built binaries (and their .sha256, which the generator + # ignores) from the release into a flat directory. + mkdir -p assets-bin + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern 'ask-*' \ + --dir assets-bin \ + --clobber + + # Generate npm/dist/ + npm/dist/ask (the wrapper). + node npm/scripts/generate-platform-packages.mjs "$VERSION_NO_V" "$PWD/assets-bin" + + # Publish every platform package first (idempotent skip-guard), then + # the wrapper last. + publish_dir() { + local dir="$1" + local name ver + name=$(node -p "require('$PWD/${dir}/package.json').name") + ver=$(node -p "require('$PWD/${dir}/package.json').version") + if npm view "$name@$ver" version 2>/dev/null; then + echo "⚠ $name@$ver already published, skipping" + else + npm publish "$dir" --provenance --access public + fi + } + + for dir in npm/dist/*/; do + dir="${dir%/}" + name=$(node -p "require('$PWD/${dir}/package.json').name") + [ "$name" = "@pleaseai/ask" ] && continue + publish_dir "$dir" done - ls -lh dist-bin - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ask-binaries - path: | - dist-bin/ask-* - if-no-files-found: error + # Wrapper LAST — its optionalDependencies now all exist on npm. + publish_dir "npm/dist/ask" - upload-release-assets: - name: Upload release assets - needs: [release-please, build-binaries] + cargo-publish: + name: Publish to crates.io + needs: release-please if: needs.release-please.outputs.cli_release_created == 'true' - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ask-binaries - path: dist-bin - - - name: Upload binaries to release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh release upload "${{ needs.release-please.outputs.cli_tag_name }}" \ - dist-bin/ask-* \ - --clobber \ - --repo "${{ github.repository }}" + uses: ./.github/workflows/release-cargo.yml + with: + dry_run: false + secrets: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + # --------------------------------------------------------------------------- + # Homebrew distribution: the binaries + checksums are already attached to the + # release by build-binaries; this job reads the published checksums and + # updates the formula in pleaseai/homebrew-tap. Asset names are unchanged. + # --------------------------------------------------------------------------- update-homebrew-formula: name: Update Homebrew formula - needs: [release-please, upload-release-assets] + needs: [release-please, build-binaries] if: needs.release-please.outputs.cli_release_created == 'true' runs-on: ubuntu-latest steps: From 34a3a2780461d405a558f8045a4a69f0a15d3c97 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 08:17:25 +0900 Subject: [PATCH 35/44] chore(claude): enable github-actions and plannotator plugins Enable the github-actions@passionfactory and plannotator@passionfactory plugins in the local Claude Code plugin configuration. --- .claude/settings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 5109e0d..f1772d2 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,7 +30,9 @@ "cloudflare@cloudflare": true, "typescript-lsp@code-intelligence": true, "eslint-lsp@code-intelligence": true, - "gardening@passionfactory": true + "gardening@passionfactory": true, + "github-actions@passionfactory": true, + "plannotator@passionfactory": true }, "extraKnownMarketplaces": { "passionfactory": { From fbd224da0ab78c5d5628e149cd9b23ddff2079e7 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 08:22:31 +0900 Subject: [PATCH 36/44] chore(ci): scope read permission to job level in release workflows SonarCloud (S6362): move the workflow-level 'permissions: contents: read' to the job that needs it in release-rust.yml (build) and release-cargo.yml (publish). release-rust's upload-release-assets job already declares its own 'contents: write'. Least-privilege per-job permissions. --- .github/workflows/release-cargo.yml | 5 ++--- .github/workflows/release-rust.yml | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-cargo.yml b/.github/workflows/release-cargo.yml index 8d8d28b..3251395 100644 --- a/.github/workflows/release-cargo.yml +++ b/.github/workflows/release-cargo.yml @@ -29,9 +29,6 @@ on: CARGO_REGISTRY_TOKEN: required: false -permissions: - contents: read - concurrency: group: release-cargo cancel-in-progress: false @@ -39,6 +36,8 @@ concurrency: jobs: publish: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/release-rust.yml b/.github/workflows/release-rust.yml index dc8545b..c2c0340 100644 --- a/.github/workflows/release-rust.yml +++ b/.github/workflows/release-rust.yml @@ -28,9 +28,6 @@ on: required: false type: string -permissions: - contents: read - concurrency: group: release-rust-${{ github.ref }} cancel-in-progress: false @@ -38,6 +35,8 @@ concurrency: jobs: build: runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: fail-fast: false matrix: From 73ae9a987ababf6201000dd0cd67932cd0632137 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 08:52:22 +0900 Subject: [PATCH 37/44] =?UTF-8?q?fix:=20apply=20cubic=20review=20=E2=80=94?= =?UTF-8?q?=20Windows/security/parity=20fixes=20across=20the=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the cubic AI review on PR #119. 14 findings fixed; 6 confirmed pre-existing-in-TS and deferred (see PR follow-up). Windows portability (cfg-gated; verified only by the release-rust CI matrix): - skills/symlinks.rs: remove directory symlinks with remove_dir on Windows (remove_file only removes file symlinks) via a shared remove_symlink helper — fixes skills relink/unlink leaving links behind on Windows. - store/mod.rs: resolve a relative symlink target against the source link's parent before the non-unix copy fallback (was copying the raw relative target against CWD). - cli.rs: parse ASK_GC_SCAN_ROOTS with std::env::split_paths (OS delimiter) so a Windows drive-letter root is not split on its ':' (unix behaviour unchanged). Security hardening (parity-orthogonal; fixtures never exercise these): - sources/npm.rs: apply the try_local_read lexical+realpath containment guard to the tarball --docs-path branch (could read arbitrary local paths). - sources/mod.rs: skip doc files whose symlink target escapes the docs root (per-file guard; the root-only guard missed symlinked files) + regression test. Schema/behaviour parity with the TS oracle (TS stricter → fix restores parity): - resolved.rs: reject empty spec/resolvedVersion (TS z.string().min(1)) + test. - ask_json.rs: reject JS line terminators in a spec payload (TS regex .+$) + test. - commands/docs.rs: propagate an invalid ask.json instead of masking it. - io.rs: read_resolved_json surfaces non-NotFound read errors instead of masking them as an empty cache (matches TS readFileSync outside the parse try/catch); returns Result — callers updated. - resolvers/maven.rs: accept latestVersion (default core) as well as v (core=gav) so a healthy Search API resolves 'latest' instead of falling through + test. - store/paths.rs: split the URL authority at the first of / ? # so a query/ fragment without a path is not lowercased into the store key (TS normalizeUrl preserves path/query/fragment case). Test harness: - scripts/parity-diff.sh: compare TS/Rust exit codes (a blanket '|| true' let the harness false-pass when both sides errored). Exempt the skills_install case with a documented note — the TS skills parent double-dispatches (subcommand + list shorthand → spurious exit 1); Rust dispatches install only, files are identical. CI publish safety: - release.yml: refuse to publish the @pleaseai/ask wrapper unless all 6 platform binaries are present — a partial build would ship a wrapper with missing optionalDependencies (npm does not error) and burn the version. 283 unit tests, clippy/fmt clean, actionlint clean, all differential parity cases byte-identical. --- .github/workflows/release.yml | 16 ++++++++++ crates/ask/src/ask_json.rs | 16 ++++++++++ crates/ask/src/cli.rs | 9 ++++-- crates/ask/src/commands/docs.rs | 14 ++++---- crates/ask/src/io.rs | 43 +++++++++++++++---------- crates/ask/src/resolved.rs | 20 ++++++++++++ crates/ask/src/resolvers/maven.rs | 32 +++++++++++++++++-- crates/ask/src/skills/symlinks.rs | 21 ++++++++++-- crates/ask/src/sources/mod.rs | 53 ++++++++++++++++++++++++++++--- crates/ask/src/sources/npm.rs | 12 +++++++ crates/ask/src/storage.rs | 8 +++-- crates/ask/src/store/mod.rs | 19 ++++++++--- crates/ask/src/store/paths.rs | 16 ++++++---- scripts/parity-diff.sh | 26 +++++++++++++-- 14 files changed, 254 insertions(+), 51 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ffa3e1..8ac94b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -168,6 +168,22 @@ jobs: --dir assets-bin \ --clobber + # Require ALL platform binaries before publishing. The wrapper pins + # optionalDependencies for every platform; a partial build (release-rust + # uses fail-fast:false, so one target can fail while others upload) would + # otherwise publish a wrapper missing a platform — npm does NOT error on + # a missing optional dep, so that platform silently gets a broken install, + # and the version number is burned permanently. Partial *release assets* + # are fine (some binaries beat none); a partial *npm wrapper* is not. + missing=() + for a in ask-darwin-arm64 ask-darwin-x64 ask-linux-x64 ask-linux-arm64 ask-linux-x64-musl ask-windows-x64.exe; do + [ -f "assets-bin/$a" ] || missing+=("$a") + done + if [ ${#missing[@]} -ne 0 ]; then + echo "::error::Refusing to publish an incomplete @pleaseai/ask wrapper — missing platform binaries: ${missing[*]}. Re-run the failed release-rust build legs, then re-run this publish." >&2 + exit 1 + fi + # Generate npm/dist/ + npm/dist/ask (the wrapper). node npm/scripts/generate-platform-packages.mjs "$VERSION_NO_V" "$PWD/assets-bin" diff --git a/crates/ask/src/ask_json.rs b/crates/ask/src/ask_json.rs index 47e10a6..93128a5 100644 --- a/crates/ask/src/ask_json.rs +++ b/crates/ask/src/ask_json.rs @@ -140,6 +140,14 @@ fn is_valid_spec_string(spec: &str) -> bool { if payload.is_empty() { return false; } + // The zod regex `^...:.+$` has no `s`/`m` flags: `.` never matches a JS line + // terminator and `$` is strict end-of-string, so a payload containing any JS + // line terminator (LF, CR, U+2028, U+2029) is rejected. Mirror that so Rust + // does not accept multiline specs the TS schema rejects. (The prefix charset + // already excludes these.) + if payload.contains(['\n', '\r', '\u{2028}', '\u{2029}']) { + return false; + } let mut chars = prefix.chars(); match chars.next() { Some(c) if c.is_ascii_lowercase() => {} @@ -207,6 +215,14 @@ mod tests { assert!(err.to_string().contains("ecosystem prefix")); } + #[test] + fn rejects_spec_with_embedded_line_terminator() { + // The `\n` here is a JSON escape → the parsed spec string contains a real + // newline. TS's `.+$` regex rejects it; Rust must too. + let err = AskJson::parse(r#"{"libraries":["npm:next\nmalicious"]}"#).unwrap_err(); + assert!(err.to_string().contains("ecosystem prefix")); + } + #[test] fn rejects_object_with_empty_docs_paths() { // Structurally valid but violates the non-empty refinement. diff --git a/crates/ask/src/cli.rs b/crates/ask/src/cli.rs index b4c626b..90007fe 100644 --- a/crates/ask/src/cli.rs +++ b/crates/ask/src/cli.rs @@ -470,9 +470,12 @@ fn run_cache_cmd(args: CacheArgs) -> anyhow::Result<()> { eprintln!("\nTotal: {}", format_bytes(total)); } CacheCommand::Gc(a) => { - let scan_roots = std::env::var("ASK_GC_SCAN_ROOTS") - .ok() - .map(|v| v.split(':').map(std::path::PathBuf::from).collect()); + // Split on the OS path-list delimiter (`:` on unix, `;` on Windows) + // via split_paths so a Windows drive-letter root (`C:\...`) is not + // mis-split on its `:`. On unix this is identical to the previous + // `:`-split. + let scan_roots = + std::env::var_os("ASK_GC_SCAN_ROOTS").map(|v| std::env::split_paths(&v).collect()); let older_than = match a.older_than.as_deref() { Some(raw) => match parse_duration(raw) { Some(ms) => Some(ms), diff --git a/crates/ask/src/commands/docs.rs b/crates/ask/src/commands/docs.rs index 8e18db6..5f66384 100644 --- a/crates/ask/src/commands/docs.rs +++ b/crates/ask/src/commands/docs.rs @@ -105,13 +105,13 @@ pub fn run_docs_with( // Persisted docsPaths override: emit ONLY the stored paths (resolved against // both roots that `ask add` probed). Falls back to the unfiltered walk when // every stored path is stale — silent empty output would be worse. - let stored: Option> = - read_ask_json(&options.project_dir) - .ok() - .flatten() - .and_then(|aj| { - find_entry(&aj, &options.spec).and_then(|e| e.docs_paths().map(|d| d.to_vec())) - }); + // Propagate an invalid ask.json rather than masking it: TS `ask docs` calls + // readAskJson directly (no try/catch), so a broken config surfaces a parse + // error instead of silently emitting the unfiltered candidate list. + let stored: Option> = match read_ask_json(&options.project_dir)? { + Some(aj) => find_entry(&aj, &options.spec).and_then(|e| e.docs_paths().map(|d| d.to_vec())), + None => None, + }; let mut stored_override = false; if let Some(stored) = stored.as_ref().filter(|s| !s.is_empty()) { diff --git a/crates/ask/src/io.rs b/crates/ask/src/io.rs index f499f2b..550c13f 100644 --- a/crates/ask/src/io.rs +++ b/crates/ask/src/io.rs @@ -141,15 +141,23 @@ pub fn write_ask_json(project_dir: &Path, ask_json: &AskJson) -> anyhow::Result< } /// Read and validate `.ask/resolved.json`. Returns the default empty cache when -/// the file is missing OR fails to parse/validate — the cache is rebuilt from -/// scratch in that case. This is the contract that makes `resolved.json` safe to -/// delete by hand. -pub fn read_resolved_json(project_dir: &Path) -> ResolvedJson { +/// the file is MISSING or fails to parse/validate — the cache is rebuilt from +/// scratch in that case, which is what makes `resolved.json` safe to delete by +/// hand. A non-NotFound read error (e.g. permission denied) is NOT masked as an +/// empty cache — it is surfaced, matching TS `readResolvedJson` where the +/// `readFileSync` sits OUTSIDE the parse `try/catch` (only parse failures fall +/// back to empty). Masking it would hide a real disk/permission fault behind a +/// silent full re-resolve. +pub fn read_resolved_json(project_dir: &Path) -> anyhow::Result { let file = get_resolved_json_path(project_dir); - let Ok(raw) = std::fs::read_to_string(&file) else { - return empty_resolved(); + let raw = match std::fs::read_to_string(&file) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(empty_resolved()), + Err(e) => { + return Err(anyhow::Error::new(e).context(format!("Failed to read {}", file.display()))) + } }; - ResolvedJson::parse(&raw).unwrap_or_else(|_| empty_resolved()) + Ok(ResolvedJson::parse(&raw).unwrap_or_else(|_| empty_resolved())) } /// The default empty resolved cache. @@ -180,7 +188,7 @@ pub fn upsert_resolved_entry( key: &str, entry: ResolvedEntry, ) -> anyhow::Result<()> { - let mut resolved = read_resolved_json(project_dir); + let mut resolved = read_resolved_json(project_dir)?; let changed = match resolved.entries.get(key) { None => true, Some(previous) => !eq_ignoring_fetched_at(previous, &entry), @@ -199,7 +207,7 @@ pub fn remove_resolved_entries(project_dir: &Path, keys: &[String]) -> anyhow::R if keys.is_empty() { return Ok(()); } - let mut resolved = read_resolved_json(project_dir); + let mut resolved = read_resolved_json(project_dir)?; for k in keys { resolved.entries.remove(k); } @@ -322,7 +330,7 @@ mod tests { #[test] fn read_missing_resolved_is_empty_default() { let dir = tmp(); - let r = read_resolved_json(dir.path()); + let r = read_resolved_json(dir.path()).unwrap(); assert_eq!(r.schema_version, 1); assert!(r.entries.is_empty()); } @@ -333,7 +341,7 @@ mod tests { let file = get_resolved_json_path(dir.path()); std::fs::create_dir_all(file.parent().unwrap()).unwrap(); std::fs::write(&file, "{ not valid json").unwrap(); - assert!(read_resolved_json(dir.path()).entries.is_empty()); + assert!(read_resolved_json(dir.path()).unwrap().entries.is_empty()); } fn sample_entry(spec: &str) -> ResolvedEntry { @@ -356,33 +364,36 @@ mod tests { fn upsert_then_remove_resolved_entry() { let dir = tmp(); upsert_resolved_entry(dir.path(), "next", sample_entry("npm:next")).unwrap(); - let r = read_resolved_json(dir.path()); + let r = read_resolved_json(dir.path()).unwrap(); assert!(r.entries.contains_key("next")); // generatedAt was stamped to a real time (not the 1970 default). assert_ne!(r.generated_at, "1970-01-01T00:00:00Z"); remove_resolved_entries(dir.path(), &["next".to_string()]).unwrap(); - assert!(!read_resolved_json(dir.path()).entries.contains_key("next")); + assert!(!read_resolved_json(dir.path()) + .unwrap() + .entries + .contains_key("next")); } #[test] fn upsert_skips_rewrite_when_only_fetched_at_differs() { let dir = tmp(); upsert_resolved_entry(dir.path(), "next", sample_entry("npm:next")).unwrap(); - let first = read_resolved_json(dir.path()).generated_at.clone(); + let first = read_resolved_json(dir.path()).unwrap().generated_at.clone(); // Same entry but a different fetched_at → treated as unchanged, no rewrite. let mut again = sample_entry("npm:next"); again.fetched_at = "2099-01-01T00:00:00Z".into(); upsert_resolved_entry(dir.path(), "next", again).unwrap(); - assert_eq!(read_resolved_json(dir.path()).generated_at, first); + assert_eq!(read_resolved_json(dir.path()).unwrap().generated_at, first); // A material change (version) → rewrite. let mut changed = sample_entry("npm:next"); changed.resolved_version = "2.0.0".into(); upsert_resolved_entry(dir.path(), "next", changed).unwrap(); assert_eq!( - read_resolved_json(dir.path()).entries["next"].resolved_version, + read_resolved_json(dir.path()).unwrap().entries["next"].resolved_version, "2.0.0" ); } diff --git a/crates/ask/src/resolved.rs b/crates/ask/src/resolved.rs index 6dd9cb5..9804bcc 100644 --- a/crates/ask/src/resolved.rs +++ b/crates/ask/src/resolved.rs @@ -88,11 +88,21 @@ pub enum ResolvedError { MissingInPlacePath(String), #[error("schemaVersion must be 1, got {0}")] SchemaVersion(u32), + #[error("{0} must be a non-empty string")] + EmptyField(&'static str), } impl ResolvedEntry { /// Enforce the field-shape refinements serde structural parsing cannot. pub fn validate(&self) -> Result<(), ResolvedError> { + // TS: spec / resolvedVersion are `z.string().min(1)` — reject empties so + // `.ask/resolved.json` acceptance stays in parity with the TS validator. + if self.spec.is_empty() { + return Err(ResolvedError::EmptyField("spec")); + } + if self.resolved_version.is_empty() { + return Err(ResolvedError::EmptyField("resolvedVersion")); + } if !is_content_hash(&self.content_hash) { return Err(ResolvedError::ContentHash(self.content_hash.clone())); } @@ -273,6 +283,16 @@ mod tests { assert!(ResolvedJson::parse(&doc(&json)).is_err()); } + #[test] + fn rejects_empty_spec_or_version() { + // TS: spec / resolvedVersion are z.string().min(1). + let empty_spec = base_entry_json("").replace(r#""spec":"npm:next""#, r#""spec":"""#); + assert!(ResolvedJson::parse(&doc(&empty_spec)).is_err()); + let empty_ver = + base_entry_json("").replace(r#""resolvedVersion":"15.0.0""#, r#""resolvedVersion":"""#); + assert!(ResolvedJson::parse(&doc(&empty_ver)).is_err()); + } + #[test] fn rejects_bad_commit_sha() { let json = base_entry_json(r#","commit":"NOTASHA""#); diff --git a/crates/ask/src/resolvers/maven.rs b/crates/ask/src/resolvers/maven.rs index 1017b2d..176727d 100644 --- a/crates/ask/src/resolvers/maven.rs +++ b/crates/ask/src/resolvers/maven.rs @@ -36,7 +36,13 @@ struct MavenSearchInner { #[derive(Deserialize)] struct MavenSearchDoc { - v: String, + // `core=gav` responses (explicit version) carry `v`; the default core (used + // for `latest`) carries `latestVersion` and has no `v`. Accept both so a + // healthy Search API can resolve `latest` instead of erroring on the missing + // `v` field and always falling through to maven-metadata.xml. + v: Option, + #[serde(rename = "latestVersion")] + latest_version: Option, } #[derive(Deserialize)] @@ -195,9 +201,11 @@ fn fetch_search_api( .docs .into_iter() .next() - .map(|d| d.v) + .and_then(|d| d.v.or(d.latest_version)) .ok_or_else(|| { - anyhow!("Maven Central Search API returned no docs for {group_id}:{artifact_id}") + anyhow!( + "Maven Central Search API returned no usable version for {group_id}:{artifact_id}" + ) })?; // Best-effort scm.url from the artifact-level (non-GAV) core. @@ -324,6 +332,24 @@ mod tests { assert_eq!(r.fallback_refs, vec!["1.2.3"]); } + #[test] + fn resolve_latest_reads_latest_version_field() { + // The default (non-GAV) core used for `latest` returns `latestVersion`, + // not `v`. The parser must accept it instead of erroring and falling + // through to maven-metadata.xml. + let latest = + "https://search.maven.org/solrsearch/select?q=g:com.example+AND+a:lib&rows=1&wt=json"; + let client = MockClient::new().with( + latest, + 200, + r#"{"response":{"numFound":1,"docs":[{"latestVersion":"2.0.0","scm.url":"https://github.com/example/lib"}]}}"#, + ); + let r = resolve(&client, "com.example:lib", "latest").unwrap(); + assert_eq!(r.resolved_version, "2.0.0"); + assert_eq!(r.repo, "example/lib"); + assert_eq!(r.ref_, "v2.0.0"); + } + #[test] fn resolve_explicit_version_falls_back_to_pom() { // Search API is down (unregistered → transport error), explicit version diff --git a/crates/ask/src/skills/symlinks.rs b/crates/ask/src/skills/symlinks.rs index 55b8abd..f354c53 100644 --- a/crates/ask/src/skills/symlinks.rs +++ b/crates/ask/src/skills/symlinks.rs @@ -51,6 +51,23 @@ fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> { } } +/// Remove a symlink previously created by [`create_symlink`]. On Windows the +/// links are directory symlinks (`symlink_dir`), and a directory symlink must +/// be removed with `remove_dir` — `remove_file` only removes file symlinks and +/// would fail, leaving the link behind. On unix `remove_file` removes any +/// symlink. Keeping removal symmetric with creation makes relink/unlink work +/// cross-platform. +fn remove_symlink(link: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + std::fs::remove_dir(link) + } + #[cfg(not(windows))] + { + std::fs::remove_file(link) + } +} + /// Create a relative symlink at `link_path` → `target_path`, creating the /// parent dir on demand. A pre-existing identical symlink is a no-op; a /// differing symlink or a real file/dir errors unless `force`. Parity with @@ -81,7 +98,7 @@ pub fn link_skill(opts: &LinkSkillOptions) -> anyhow::Result<()> { } .into()); } - std::fs::remove_file(opts.link_path)?; + remove_symlink(opts.link_path)?; } else { if !opts.force { return Err(SymlinkConflictError { @@ -122,7 +139,7 @@ pub fn unlink_if_owned(link_path: &Path, expected_target: &Path) -> bool { if current != rel_expected { return false; } - std::fs::remove_file(link_path).is_ok() + remove_symlink(link_path).is_ok() } #[cfg(test)] diff --git a/crates/ask/src/sources/mod.rs b/crates/ask/src/sources/mod.rs index 71253ec..c5a7831 100644 --- a/crates/ask/src/sources/mod.rs +++ b/crates/ask/src/sources/mod.rs @@ -94,18 +94,39 @@ pub fn is_doc_file(name: &str) -> bool { /// `base` (forward-slash separated), sorted for determinism. pub fn collect_doc_files(base: &Path, current: &Path) -> std::io::Result> { let mut files = Vec::new(); - collect_into(base, current, &mut files)?; + // Canonicalized base for the per-file symlink-escape guard in collect_into. + // `None` (base itself unresolvable) leaves non-symlink collection working. + let canon_base = std::fs::canonicalize(base).ok(); + collect_into(base, canon_base.as_deref(), current, &mut files)?; files.sort_by(|a, b| a.path.cmp(&b.path)); Ok(files) } -fn collect_into(base: &Path, current: &Path, out: &mut Vec) -> std::io::Result<()> { +fn collect_into( + base: &Path, + canon_base: Option<&Path>, + current: &Path, + out: &mut Vec, +) -> std::io::Result<()> { for entry in std::fs::read_dir(current)? { let entry = entry?; let path = entry.path(); - if entry.file_type()?.is_dir() { - collect_into(base, &path, out)?; + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_into(base, canon_base, &path, out)?; } else if is_doc_file(&entry.file_name().to_string_lossy()) { + // A symlinked doc file can point outside the docs root; the root-only + // containment guard in extract_docs_from_dir does not cover per-file + // links. Verify a symlink's real target stays within the canonical + // base before reading, so a checkout can't exfiltrate arbitrary files. + // Non-symlink files are physically inside the tree — no check needed, + // so normal collection stays byte-identical. + if file_type.is_symlink() { + match (canon_base, std::fs::canonicalize(&path)) { + (Some(cb), Ok(real)) if real.starts_with(cb) => {} + _ => continue, // escapes base, or unresolvable → skip + } + } let rel = path.strip_prefix(base).unwrap_or(&path); let rel_str = rel .components() @@ -206,6 +227,30 @@ mod tests { assert_eq!(paths, vec!["a.md", "sub/b.mdx"]); } + // A symlinked doc file that escapes the docs root must be skipped (not read), + // while an in-tree symlink resolving inside the root is still collected. + #[cfg(unix)] + #[test] + fn collect_skips_escaping_symlink_files() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let outside = root.join("secret.md"); + std::fs::write(&outside, "TOP SECRET").unwrap(); + let docs = root.join("docs"); + std::fs::create_dir_all(&docs).unwrap(); + std::fs::write(docs.join("real.md"), "REAL").unwrap(); + // Escaping link: docs/leak.md -> ../secret.md (outside docs root). + std::os::unix::fs::symlink("../secret.md", docs.join("leak.md")).unwrap(); + // In-tree link: docs/alias.md -> real.md (inside docs root). + std::os::unix::fs::symlink("real.md", docs.join("alias.md")).unwrap(); + + let files = collect_doc_files(&docs, &docs).unwrap(); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + // leak.md is dropped; real.md and the in-tree alias.md survive. + assert_eq!(paths, vec!["alias.md", "real.md"]); + assert!(files.iter().all(|f| f.content != "TOP SECRET")); + } + #[test] fn extract_auto_detects_docs_dir() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ask/src/sources/npm.rs b/crates/ask/src/sources/npm.rs index b4a0f0c..5bf70e3 100644 --- a/crates/ask/src/sources/npm.rs +++ b/crates/ask/src/sources/npm.rs @@ -181,12 +181,24 @@ fn fetch_from_tarball( })?; let docs_dir = package_dir.join(&docs_path); + // Containment guard: a `--docs-path` that is absolute, uses `../`, or points + // at a symlink escaping the package must not let ask read arbitrary local + // paths. Mirror try_local_read's lexical + realpath checks (the local-first + // path had them; the tarball path did not). + if !lexical_clean(&docs_dir).starts_with(lexical_clean(&package_dir)) { + bail!("Docs path \"{docs_path}\" escapes the package directory in {spec}."); + } if !docs_dir.exists() { bail!( "Docs path \"{docs_path}\" not found in {spec}. Available paths:\n{}", list_dirs(&package_dir) ); } + let real_pkg = std::fs::canonicalize(&package_dir)?; + let real_docs = std::fs::canonicalize(&docs_dir)?; + if !real_docs.starts_with(&real_pkg) { + bail!("Docs path \"{docs_path}\" resolves outside the package directory in {spec}."); + } let files = if docs_dir.is_file() { let content = std::fs::read_to_string(&docs_dir)?; diff --git a/crates/ask/src/storage.rs b/crates/ask/src/storage.rs index 96f3fa0..e643d3d 100644 --- a/crates/ask/src/storage.rs +++ b/crates/ask/src/storage.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; pub use crate::ask_json::StoreMode; -use crate::io::{get_ask_dir, read_ask_json, read_resolved_json}; +use crate::io::{empty_resolved, get_ask_dir, read_ask_json, read_resolved_json}; use crate::resolved::{EntryFormat, Materialization}; use crate::sources::DocFile; use crate::spec::library_name_from_spec; @@ -168,7 +168,11 @@ pub fn list_docs(project_dir: &Path) -> Vec { let Ok(Some(ask_json)) = read_ask_json(project_dir) else { return Vec::new(); }; - let resolved = read_resolved_json(project_dir); + // `ask list` is a display-only, infallible lister (it already returns empty + // on an unreadable ask.json). It performs no rewrites, so masking a resolved + // read error here can't trigger the incorrect-rewrite bug the read_resolved_json + // change guards against — keep the infallible signature with an empty fallback. + let resolved = read_resolved_json(project_dir).unwrap_or_else(|_| empty_resolved()); let mut out = Vec::new(); for entry in &ask_json.libraries { diff --git a/crates/ask/src/store/mod.rs b/crates/ask/src/store/mod.rs index d03dd86..757e76d 100644 --- a/crates/ask/src/store/mod.rs +++ b/crates/ask/src/store/mod.rs @@ -126,7 +126,7 @@ fn copy_tree_verbatim(src: &Path, dst: &Path) -> anyhow::Result<()> { let to = dst.join(entry.file_name()); if file_type.is_symlink() { let target = std::fs::read_link(&from)?; - symlink_verbatim(&target, &to)?; + symlink_verbatim(&target, &from, &to)?; } else if file_type.is_dir() { copy_tree_verbatim(&from, &to)?; } else { @@ -137,15 +137,26 @@ fn copy_tree_verbatim(src: &Path, dst: &Path) -> anyhow::Result<()> { } #[cfg(unix)] -fn symlink_verbatim(target: &Path, link: &Path) -> std::io::Result<()> { +fn symlink_verbatim(target: &Path, _src_link: &Path, link: &Path) -> std::io::Result<()> { std::os::unix::fs::symlink(target, link) } #[cfg(not(unix))] -fn symlink_verbatim(target: &Path, link: &Path) -> std::io::Result<()> { +fn symlink_verbatim(target: &Path, src_link: &Path, link: &Path) -> std::io::Result<()> { // Windows requires privileges/target-type for symlinks; fall back to copying // the resolved file (best-effort; the store gotchas this guards are unix). - std::fs::copy(target, link).map(|_| ()) + // `target` may be RELATIVE to the source link's directory, so resolve it + // against `src_link.parent()` before copying — copying the raw relative + // target would resolve against the process CWD and read the wrong file / fail. + let resolved = if target.is_absolute() { + target.to_path_buf() + } else { + src_link + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(target) + }; + std::fs::copy(&resolved, link).map(|_| ()) } // ── Entry locking ────────────────────────────────────────────────── diff --git a/crates/ask/src/store/paths.rs b/crates/ask/src/store/paths.rs index 33f24bb..fc5a264 100644 --- a/crates/ask/src/store/paths.rs +++ b/crates/ask/src/store/paths.rs @@ -139,17 +139,19 @@ pub fn normalize_url(url: &str) -> String { if let Some(scheme_end) = stripped.find("://") { let scheme = &stripped[..scheme_end]; let rest = &stripped[scheme_end + 3..]; - let (authority, path) = match rest.find('/') { - Some(i) => (&rest[..i], &rest[i..]), - None => (rest, ""), - }; - // Authority may carry userinfo@host:port; lowercase the whole authority - // (host is the case-relevant part; userinfo/port are ASCII-safe here). + // The authority ends at the FIRST of `/`, `?`, or `#`. Splitting only on + // `/` lets a URL with a query/fragment but no path (`host?Query`) fold the + // query/fragment into the authority and lowercase it — collapsing distinct + // URLs to the same store key. TS normalizeUrl lowercases only host+scheme + // and preserves path/query/fragment case; mirror that here. + let split_at = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..split_at]; + let suffix = &rest[split_at..]; format!( "{}://{}{}", scheme.to_lowercase(), authority.to_lowercase(), - path + suffix ) } else { stripped.to_lowercase() diff --git a/scripts/parity-diff.sh b/scripts/parity-diff.sh index 22d6915..19b98ed 100755 --- a/scripts/parity-diff.sh +++ b/scripts/parity-diff.sh @@ -59,10 +59,21 @@ run_case() { # Each remaining arg is ONE command line (word-split here), run in order in # both copies — supports sequences like `install` then `remove react`. # Scrub env so inherited BUN_*/TTY state cannot perturb either side. - local cmd + # + # Capture and COMPARE exit codes rather than blanket-`|| true`. A blanket + # ignore lets the harness false-pass when BOTH sides error (e.g. both crash + # on the same regression and write nothing → identical empty trees). By + # requiring the exit codes to agree, an asymmetric success/failure — the + # exact divergence a byte-diff of generated files can miss — is caught even + # when the files happen to match. The file diff below still runs on top. + local cmd rc_ts rc_rs for cmd in "$@"; do - ( cd "$ts" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 node "$TS_CLI" $cmd >/dev/null 2>&1 ) || true - ( cd "$rs" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 "$RS_CLI" $cmd >/dev/null 2>&1 ) || true + if ( cd "$ts" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 node "$TS_CLI" $cmd >/dev/null 2>&1 ); then rc_ts=0; else rc_ts=$?; fi + if ( cd "$rs" && env -i PATH="$PATH" HOME="$HOME" NO_COLOR=1 "$RS_CLI" $cmd >/dev/null 2>&1 ); then rc_rs=0; else rc_rs=$?; fi + if [[ "$rc_ts" != "$rc_rs" ]]; then + echo " FAIL $name ($cmd): exit code mismatch (ts=$rc_ts rs=$rc_rs)" + fail=1 + fi done if diff -r "$ts" "$rs" >"$WORK/$name.diff" 2>&1; then echo " ok $name ($*)" @@ -147,6 +158,15 @@ skills_install_parity() { mkdir -p "$d/.claude" printf 'node_modules\n' >"$d/.gitignore" done + # Exit codes are intentionally NOT compared for this case (only the files are). + # The TS `skills` parent command (commands/skills/index.ts) declares BOTH + # `subCommands` AND its own `run()` with a positional `spec`, so citty runs the + # `install` subcommand AND then fires the parent run() with spec="install", + # which calls runSkillsList → "no skills/ directory found for install" → exit 1. + # That double-dispatch is a TS bug; the Rust router dispatches `skills install` + # to install ONLY (exit 0). Both produce byte-identical files (verified below) — + # replicating the spurious exit 1 in Rust would mean porting the bug, so we do + # not. (run_case still compares exit codes for the non-skills commands.) ( cd "$ts" && env -i PATH="$PATH" HOME="$HOME" ASK_HOME="$home" NO_COLOR=1 node "$TS_CLI" skills install github:o/r@v1.0.0 --agent claude >/dev/null 2>&1 ) || true ( cd "$rs" && env -i PATH="$PATH" HOME="$HOME" ASK_HOME="$home" NO_COLOR=1 "$RS_CLI" skills install github:o/r@v1.0.0 --agent claude >/dev/null 2>&1 ) || true for d in "$ts" "$rs"; do From a8316bd15842ae36225938bd7dff8933c0537507 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 08:59:29 +0900 Subject: [PATCH 38/44] fix: add containment guard to non-unix symlink copy in cp_dir_atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the relative-symlink resolution in the prior commit (cubic P1 on 73ae9a9). The non-unix fallback dereferences a symlink target and copies its bytes into the store entry; without a containment check a target that resolves outside the copy root (../…, absolute, or an escaping chain) would pull unrelated local files into the cached entry. Thread the copy root through copy_tree_verbatim and canonicalize the resolved target against it — skip the entry (copy nothing) when it escapes or cannot be resolved. Unix is unchanged: it records the link verbatim and never dereferences the target. --- crates/ask/src/store/mod.rs | 41 ++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/ask/src/store/mod.rs b/crates/ask/src/store/mod.rs index 757e76d..61e4437 100644 --- a/crates/ask/src/store/mod.rs +++ b/crates/ask/src/store/mod.rs @@ -105,7 +105,9 @@ pub fn cp_dir_atomic(source_dir: &Path, target_dir: &Path) -> anyhow::Result<()> std::fs::create_dir_all(parent)?; } let result = (|| -> anyhow::Result<()> { - copy_tree_verbatim(source_dir, &tmp_dir)?; + // `source_dir` is the containment root: a (non-unix) symlink whose target + // dereferences outside it must not pull unrelated files into the entry. + copy_tree_verbatim(source_dir, source_dir, &tmp_dir)?; atomic_swap(&tmp_dir, target_dir)?; Ok(()) })(); @@ -117,7 +119,7 @@ pub fn cp_dir_atomic(source_dir: &Path, target_dir: &Path) -> anyhow::Result<()> /// Recursively copy `src` into `dst`, copying symlinks as symlinks (verbatim /// target) rather than following them. -fn copy_tree_verbatim(src: &Path, dst: &Path) -> anyhow::Result<()> { +fn copy_tree_verbatim(root: &Path, src: &Path, dst: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; @@ -126,9 +128,9 @@ fn copy_tree_verbatim(src: &Path, dst: &Path) -> anyhow::Result<()> { let to = dst.join(entry.file_name()); if file_type.is_symlink() { let target = std::fs::read_link(&from)?; - symlink_verbatim(&target, &from, &to)?; + symlink_verbatim(root, &target, &from, &to)?; } else if file_type.is_dir() { - copy_tree_verbatim(&from, &to)?; + copy_tree_verbatim(root, &from, &to)?; } else { std::fs::copy(&from, &to)?; } @@ -137,12 +139,24 @@ fn copy_tree_verbatim(src: &Path, dst: &Path) -> anyhow::Result<()> { } #[cfg(unix)] -fn symlink_verbatim(target: &Path, _src_link: &Path, link: &Path) -> std::io::Result<()> { +fn symlink_verbatim( + _root: &Path, + target: &Path, + _src_link: &Path, + link: &Path, +) -> std::io::Result<()> { + // Unix records the link verbatim — it never dereferences the target, so an + // escaping target is just a link, not a copy of external content. std::os::unix::fs::symlink(target, link) } #[cfg(not(unix))] -fn symlink_verbatim(target: &Path, src_link: &Path, link: &Path) -> std::io::Result<()> { +fn symlink_verbatim( + root: &Path, + target: &Path, + src_link: &Path, + link: &Path, +) -> std::io::Result<()> { // Windows requires privileges/target-type for symlinks; fall back to copying // the resolved file (best-effort; the store gotchas this guards are unix). // `target` may be RELATIVE to the source link's directory, so resolve it @@ -156,7 +170,20 @@ fn symlink_verbatim(target: &Path, src_link: &Path, link: &Path) -> std::io::Res .unwrap_or_else(|| Path::new(".")) .join(target) }; - std::fs::copy(&resolved, link).map(|_| ()) + // Containment: unlike unix (which only records the link), this branch COPIES + // the target's bytes into the entry. A symlink whose target dereferences + // outside the copy root (`../…`, an absolute path, or an escaping chain) + // would exfiltrate unrelated local files into the cached entry — so canonicalize + // both and skip the entry when the target escapes the root or cannot be resolved. + match ( + std::fs::canonicalize(root), + std::fs::canonicalize(&resolved), + ) { + (Ok(real_root), Ok(real_target)) if real_target.starts_with(&real_root) => { + std::fs::copy(&real_target, link).map(|_| ()) + } + _ => Ok(()), // escapes the root or unresolvable → skip, do not exfiltrate + } } // ── Entry locking ────────────────────────────────────────────────── From e39309bc2252e8a63597bdb1772c23b21df19275 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 10:11:29 +0900 Subject: [PATCH 39/44] fix: platform-aware home dir for cache gc scan root (Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit greptile P1: `ask cache gc` defaulted its scan root to `std::env::var("HOME")`, which is unset on native Windows (Windows uses USERPROFILE). An empty scan root makes collect_referenced_store_paths return an empty set, so every entry looks unreferenced and a non-dry-run gc deletes the entire store — including in-use entries. Use the existing platform-aware store::paths::home_dir() (USERPROFILE on Windows, $HOME on unix), matching TS os.homedir(). home_dir is now pub(crate). Also document why release-rust.yml uses `macos-15-intel` (a second greptile P1 claimed it was invalid, but actionlint's runner-label DB confirms macos-15-intel is the current Intel label and macos-13 has been retired — the original label was correct; comment added to prevent a regression to the retired macos-13). --- .github/workflows/release-rust.yml | 2 +- crates/ask/src/store/cache.rs | 11 ++++++----- crates/ask/src/store/paths.rs | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-rust.yml b/.github/workflows/release-rust.yml index c2c0340..6d24492 100644 --- a/.github/workflows/release-rust.yml +++ b/.github/workflows/release-rust.yml @@ -44,7 +44,7 @@ jobs: - os: macos-14 # Apple Silicon target: aarch64-apple-darwin asset: ask-darwin-arm64 - - os: macos-15-intel # Intel + - os: macos-15-intel # Intel (macos-13 was retired; macos-15-intel is the current Intel label) target: x86_64-apple-darwin asset: ask-darwin-x64 - os: ubuntu-latest diff --git a/crates/ask/src/store/cache.rs b/crates/ask/src/store/cache.rs index adef4ac..9d629d6 100644 --- a/crates/ask/src/store/cache.rs +++ b/crates/ask/src/store/cache.rs @@ -321,11 +321,12 @@ pub struct CacheGcResult { /// still kept. pub fn cache_gc(ask_home: &Path, options: &CacheGcOptions) -> CacheGcResult { let scan_roots = options.scan_roots.clone().unwrap_or_else(|| { - std::env::var("HOME") - .ok() - .filter(|h| !h.is_empty()) - .map(|h| vec![PathBuf::from(h)]) - .unwrap_or_default() + // Platform-aware home (USERPROFILE on Windows, $HOME on unix), matching + // TS's os.homedir(). Defaulting to only $HOME leaves scan_roots EMPTY on + // native Windows → collect_referenced_store_paths returns an empty set → + // every entry looks unreferenced → a non-dry-run `ask cache gc` deletes + // the whole store, including in-use entries. + vec![super::paths::home_dir()] }); let referenced = collect_referenced_store_paths(&scan_roots, ask_home); diff --git a/crates/ask/src/store/paths.rs b/crates/ask/src/store/paths.rs index fc5a264..8e52e9b 100644 --- a/crates/ask/src/store/paths.rs +++ b/crates/ask/src/store/paths.rs @@ -21,7 +21,7 @@ pub fn resolve_ask_home() -> PathBuf { } /// Best-effort home directory (`$HOME` on unix, `%USERPROFILE%` on Windows). -fn home_dir() -> PathBuf { +pub(crate) fn home_dir() -> PathBuf { #[cfg(windows)] let key = "USERPROFILE"; #[cfg(not(windows))] From a9f693a0904b68e1db740dbca575ce4f29ce1838 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 10:57:23 +0900 Subject: [PATCH 40/44] =?UTF-8?q?fix:=20apply=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20error=20propagation,=20exit=20code,=20atomicity,=20?= =?UTF-8?q?symlink/URL=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the CodeRabbit review on PR #119. 6 findings fixed; 5 confirmed faithful mirrors of the TS oracle (fixing only Rust would break byte-parity) and deferred/answered. Fixed: - agents.rs: generate_agents_md/strip_block/update_claude_md now return Result and propagate fs write/remove errors instead of swallowing them via `let _ =`. TS uses bare writeFileSync (throws on failure); Rust was masking a failed AGENTS.md/CLAUDE.md write behind a "success" install. Bubbled through run_install. - main.rs: a runtime command failure (install/remove/list) now exits 1, matching the TS citty runMain (was exit 2). clap's own usage/parse errors still exit 2. - io.rs: write_ask_json/write_resolved_json write atomically (temp file + fsync + rename) so a crash mid-write can't truncate ask.json (the source of truth, no self-heal). Same final bytes → parity-preserving. - skills/vendor.rs: copy_dir_recursive stats the symlink TARGET (follows, like fs.cpSync) so a symlink-to-directory recurses instead of hitting fs::copy (which errors on a dir). Regression test added. - sources/llms_txt.rs: url_last_segment takes the LAST path segment (even a trailing-slash empty one) then falls back to llms.txt, matching TS `pathname.split('/').pop() || 'llms.txt'`; the previous rfind(non-empty) turned `.../docs/` into `docs`. Test extended. - release.yml: pass the release tag through env (CLI_TAG_NAME) instead of interpolating the GHA expression into the script body — the safe pattern the homebrew job already uses; silences the script-injection SAST flag. Deferred (faithful TS mirrors — see PR replies / #120): scoped-@ add classification, collectSkillDirs full-path dedup, ensure_checkout vestigial is_from_branch, agents single-marker append, Windows symlink error UX. 284 tests, clippy/fmt/actionlint clean, all parity cases byte-identical. --- .github/workflows/release.yml | 7 +++- crates/ask/src/agents.rs | 51 ++++++++++++++++-------------- crates/ask/src/install.rs | 4 +-- crates/ask/src/io.rs | 29 +++++++++++------ crates/ask/src/main.rs | 8 +++-- crates/ask/src/skills/vendor.rs | 35 ++++++++++++++++++-- crates/ask/src/sources/llms_txt.rs | 16 ++++++---- 7 files changed, 103 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ac94b4..a5c223e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,12 +147,17 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GH_TOKEN: ${{ github.token }} + # Pass the tag through env rather than interpolating the expression + # directly into the script body — the same safe pattern used by + # update-homebrew-formula — so static analysis doesn't flag it as a + # script-injection sink. + CLI_TAG_NAME: ${{ needs.release-please.outputs.cli_tag_name }} run: | set -euo pipefail # Resolve the release tag: the release path passes it through from # release-please; publish_only re-derives it from the current version. - TAG="${{ needs.release-please.outputs.cli_tag_name }}" + TAG="$CLI_TAG_NAME" if [ -z "$TAG" ]; then TAG="ask-v$(node -p "require('./packages/cli/package.json').version")" fi diff --git a/crates/ask/src/agents.rs b/crates/ask/src/agents.rs index beb3965..4e0c305 100644 --- a/crates/ask/src/agents.rs +++ b/crates/ask/src/agents.rs @@ -17,12 +17,15 @@ pub struct LazyLibraryInfo { /// Regenerate the ask-docs block in `AGENTS.md`. With no libraries, strips a /// previously generated block (removing the file if it becomes empty). Returns /// the AGENTS.md path (empty string when the file was left absent/untouched). -pub fn generate_agents_md(project_dir: &Path, libraries: &[LazyLibraryInfo]) -> String { +pub fn generate_agents_md( + project_dir: &Path, + libraries: &[LazyLibraryInfo], +) -> anyhow::Result { let agents_path = project_dir.join("AGENTS.md"); if libraries.is_empty() { - strip_block(project_dir, &agents_path); - return String::new(); + strip_block(project_dir, &agents_path)?; + return Ok(String::new()); } let sections: Vec = libraries @@ -70,27 +73,27 @@ pub fn generate_agents_md(project_dir: &Path, libraries: &[LazyLibraryInfo]) -> &existing[..begin], &existing[end + END_MARKER.len()..] ); - let _ = std::fs::write(&agents_path, updated); + std::fs::write(&agents_path, updated)?; } _ => { - let _ = std::fs::write( + std::fs::write( &agents_path, format!("{}\n\n{generated_block}\n", existing.trim_end()), - ); + )?; } } } else { - let _ = std::fs::write(&agents_path, format!("{generated_block}\n")); + std::fs::write(&agents_path, format!("{generated_block}\n"))?; } - update_claude_md(project_dir); - agents_path.to_string_lossy().into_owned() + update_claude_md(project_dir)?; + Ok(agents_path.to_string_lossy().into_owned()) } /// Strip a previously generated block for the empty-libraries case. -fn strip_block(project_dir: &Path, agents_path: &Path) { +fn strip_block(project_dir: &Path, agents_path: &Path) -> anyhow::Result<()> { let Ok(existing) = std::fs::read_to_string(agents_path) else { - return; + return Ok(()); }; match (existing.find(BEGIN_MARKER), existing.find(END_MARKER)) { (Some(begin), Some(end)) => { @@ -104,9 +107,9 @@ fn strip_block(project_dir: &Path, agents_path: &Path) { format!("{head}\n\n{tail}") }; if stripped.is_empty() { - let _ = std::fs::remove_file(agents_path); + std::fs::remove_file(agents_path)?; } else { - let _ = std::fs::write(agents_path, stripped); + std::fs::write(agents_path, stripped)?; } } (Some(_), None) | (None, Some(_)) => { @@ -121,24 +124,26 @@ fn strip_block(project_dir: &Path, agents_path: &Path) { } (None, None) => {} } + Ok(()) } /// Ensure `CLAUDE.md` references `@AGENTS.md`. -fn update_claude_md(project_dir: &Path) { +fn update_claude_md(project_dir: &Path) -> anyhow::Result<()> { let claude_path = project_dir.join("CLAUDE.md"); let claude_ref = "@AGENTS.md"; match std::fs::read_to_string(&claude_path) { Ok(content) if content.contains(claude_ref) => {} Ok(content) => { - let _ = std::fs::write( + std::fs::write( &claude_path, format!("{}\n{claude_ref}\n", content.trim_end()), - ); + )?; } Err(_) => { - let _ = std::fs::write(&claude_path, format!("{claude_ref}\n")); + std::fs::write(&claude_path, format!("{claude_ref}\n"))?; } } + Ok(()) } #[cfg(test)] @@ -156,7 +161,7 @@ mod tests { #[test] fn generates_block_and_claude_ref() { let dir = tempfile::tempdir().unwrap(); - let path = generate_agents_md(dir.path(), &[lib("next", "15.0.3")]); + let path = generate_agents_md(dir.path(), &[lib("next", "15.0.3")]).unwrap(); assert!(!path.is_empty()); let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); assert!(content.starts_with(BEGIN_MARKER)); @@ -178,7 +183,7 @@ mod tests { format!("# My Project\n\nIntro.\n\n{BEGIN_MARKER}\nold\n{END_MARKER}\n\n## Footer\n"), ) .unwrap(); - generate_agents_md(dir.path(), &[lib("vue", "3.4.0")]); + generate_agents_md(dir.path(), &[lib("vue", "3.4.0")]).unwrap(); let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); assert!(content.starts_with("# My Project")); assert!(content.contains("## Footer")); @@ -190,7 +195,7 @@ mod tests { fn appends_block_when_no_markers_present() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("AGENTS.md"), "# Existing\n").unwrap(); - generate_agents_md(dir.path(), &[lib("next", "1.0.0")]); + generate_agents_md(dir.path(), &[lib("next", "1.0.0")]).unwrap(); let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); assert!(content.starts_with("# Existing")); assert!(content.contains(BEGIN_MARKER)); @@ -204,7 +209,7 @@ mod tests { format!("# Head\n\n{BEGIN_MARKER}\nx\n{END_MARKER}\n\n## Tail\n"), ) .unwrap(); - let result = generate_agents_md(dir.path(), &[]); + let result = generate_agents_md(dir.path(), &[]).unwrap(); assert_eq!(result, ""); let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap(); assert_eq!(content, "# Head\n\n## Tail\n"); @@ -219,7 +224,7 @@ mod tests { format!("{BEGIN_MARKER}\nx\n{END_MARKER}"), ) .unwrap(); - generate_agents_md(dir.path(), &[]); + generate_agents_md(dir.path(), &[]).unwrap(); assert!(!dir.path().join("AGENTS.md").exists()); } @@ -227,7 +232,7 @@ mod tests { fn does_not_duplicate_claude_ref() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("CLAUDE.md"), "# Rules\n@AGENTS.md\n").unwrap(); - generate_agents_md(dir.path(), &[lib("next", "1.0.0")]); + generate_agents_md(dir.path(), &[lib("next", "1.0.0")]).unwrap(); let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap(); assert_eq!(content.matches("@AGENTS.md").count(), 1); } diff --git a/crates/ask/src/install.rs b/crates/ask/src/install.rs index d4c0721..d873c2b 100644 --- a/crates/ask/src/install.rs +++ b/crates/ask/src/install.rs @@ -62,7 +62,7 @@ pub fn run_install( // Full install after the last library was removed still needs to strip // the AGENTS.md block and ignore markers. if options.only_specs.is_none() { - generate_agents_md(project_dir, &[]); + generate_agents_md(project_dir, &[])?; manage_ignore_files(project_dir, IgnoreMode::Remove)?; } eprintln!("No libraries to install."); @@ -94,7 +94,7 @@ pub fn run_install( } else { resolved }; - generate_agents_md(project_dir, &all_resolved); + generate_agents_md(project_dir, &all_resolved)?; manage_ignore_files(project_dir, IgnoreMode::Install)?; eprintln!( diff --git a/crates/ask/src/io.rs b/crates/ask/src/io.rs index 550c13f..335f700 100644 --- a/crates/ask/src/io.rs +++ b/crates/ask/src/io.rs @@ -5,6 +5,7 @@ //! materialized, gitignored output (docs + the resolved cache). use std::collections::BTreeMap; +use std::io::Write as _; use std::path::{Path, PathBuf}; use serde::Serialize; @@ -72,6 +73,22 @@ pub fn content_hash(files: &[HashableFile]) -> String { format!("sha256-{:x}", hash.finalize()) } +/// Crash-safe write: stage the bytes in a sibling temp file, fsync, then rename +/// into place. `std::fs::write` truncates the target first, so a crash/kill/full +/// disk mid-write leaves a truncated file — fatal for `ask.json` (the declarative +/// source of truth, which has no self-heal path). The rename is atomic on the +/// same filesystem, so a reader ever sees either the old file or the complete +/// new one. Same final bytes as `fs::write`, so parity is unaffected. +fn write_atomic(path: &Path, contents: &str) -> anyhow::Result<()> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(dir)?; + let mut tmp = tempfile::NamedTempFile::new_in(dir)?; + tmp.write_all(contents.as_bytes())?; + tmp.as_file().sync_all()?; + tmp.persist(path).map_err(|e| e.error)?; + Ok(()) +} + pub fn get_ask_dir(project_dir: &Path) -> PathBuf { project_dir.join(ASK_DIR) } @@ -133,11 +150,7 @@ pub fn read_ask_json(project_dir: &Path) -> anyhow::Result> { pub fn write_ask_json(project_dir: &Path, ask_json: &AskJson) -> anyhow::Result<()> { ask_json.validate()?; let file = get_ask_json_path(project_dir); - if let Some(parent) = file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&file, sorted_json(ask_json))?; - Ok(()) + write_atomic(&file, &sorted_json(ask_json)) } /// Read and validate `.ask/resolved.json`. Returns the default empty cache when @@ -173,11 +186,7 @@ pub fn empty_resolved() -> ResolvedJson { pub fn write_resolved_json(project_dir: &Path, resolved: &ResolvedJson) -> anyhow::Result<()> { resolved.validate()?; let file = get_resolved_json_path(project_dir); - if let Some(parent) = file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&file, sorted_json(resolved))?; - Ok(()) + write_atomic(&file, &sorted_json(resolved)) } /// Upsert a single entry into `.ask/resolved.json`. Skips the rewrite when diff --git a/crates/ask/src/main.rs b/crates/ask/src/main.rs index bc46eac..1161756 100644 --- a/crates/ask/src/main.rs +++ b/crates/ask/src/main.rs @@ -1,6 +1,6 @@ //! Binary entrypoint for `ask`. Thin wrapper: parse argv, dispatch, print any //! error to stderr and exit non-zero (clap already handles `--help`/`--version` -//! and usage errors with its own exit codes). +//! and usage errors with its own exit codes — those stay clap's 2). use std::process::ExitCode; @@ -11,9 +11,13 @@ fn main() -> ExitCode { let cli = Cli::parse(); match cli::run(cli) { Ok(()) => ExitCode::SUCCESS, + // A runtime command failure (install/remove/list bubble here) exits 1, + // matching the TS CLI's citty runMain, which exits 1 on a thrown command + // error. clap's own usage/parse errors still exit 2 (handled in + // Cli::parse above, before cli::run), which is the correct split. Err(err) => { eprintln!("ask: {err}"); - ExitCode::from(2) + ExitCode::from(1) } } } diff --git a/crates/ask/src/skills/vendor.rs b/crates/ask/src/skills/vendor.rs index f20377a..f48a3b5 100644 --- a/crates/ask/src/skills/vendor.rs +++ b/crates/ask/src/skills/vendor.rs @@ -25,13 +25,22 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; - let ft = entry.file_type()?; let from = entry.path(); let to = dst.join(entry.file_name()); - if ft.is_dir() { + // Stat the TARGET (follow symlinks), not the link itself: entry.file_type() + // reports the link's own type, so a symlink pointing at a directory would + // take the copy branch and std::fs::copy would fail (it cannot read a + // directory as file bytes). Node's fs.cpSync(recursive) dereferences here, + // so a symlinked subdir must recurse. Fall back to the entry type when the + // target is unreadable (e.g. a dangling symlink) so std::fs::copy surfaces + // the original error as before. + let is_dir = std::fs::metadata(&from) + .map(|m| m.is_dir()) + .unwrap_or_else(|_| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)); + if is_dir { copy_dir_recursive(&from, &to)?; } else { - // Symlinks are dereferenced by std::fs::copy, matching Node's default. + // Regular files (and file symlinks) are dereferenced by std::fs::copy. std::fs::copy(&from, &to)?; } } @@ -148,4 +157,24 @@ mod tests { // Second removal is a no-op. remove_vendor_dir(proj.path(), "k").unwrap(); } + + // A symlink pointing at a directory must be dereferenced and copied as a + // tree (Node fs.cpSync behaviour), not hit std::fs::copy (which errors on a + // directory). entry.file_type() reports the link's type, so the fix stats + // the target. + #[cfg(unix)] + #[test] + fn copies_through_a_directory_symlink() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + let real = src.join("real"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write(real.join("a.md"), "A").unwrap(); + std::os::unix::fs::symlink("real", src.join("link")).unwrap(); + let dst = dir.path().join("dst"); + copy_dir_recursive(&src, &dst).unwrap(); + assert_eq!(std::fs::read_to_string(dst.join("real/a.md")).unwrap(), "A"); + // The symlinked directory's contents are copied through. + assert_eq!(std::fs::read_to_string(dst.join("link/a.md")).unwrap(), "A"); + } } diff --git a/crates/ask/src/sources/llms_txt.rs b/crates/ask/src/sources/llms_txt.rs index 793d0b5..4ca645a 100644 --- a/crates/ask/src/sources/llms_txt.rs +++ b/crates/ask/src/sources/llms_txt.rs @@ -63,15 +63,15 @@ pub fn fetch( }) } -/// The last non-empty path segment of `url`, or `llms.txt` (parity with TS -/// `urlObj.pathname.split('/').pop() || 'llms.txt'`). +/// The last path segment of `url`, or `llms.txt` (parity with TS +/// `urlObj.pathname.split('/').pop() || 'llms.txt'`). TS's `pop()` takes the +/// LAST segment even when it is empty (a trailing slash), so `.../docs/` falls +/// back to `llms.txt` — using `rfind(non-empty)` here would wrongly yield `docs`. fn url_last_segment(url: &str) -> String { url::Url::parse(url) .ok() - .and_then(|u| { - let mut segs = u.path_segments()?; - segs.rfind(|seg: &&str| !seg.is_empty()).map(str::to_string) - }) + .and_then(|u| u.path_segments()?.next_back().map(str::to_string)) + .filter(|seg| !seg.is_empty()) .unwrap_or_else(|| "llms.txt".to_string()) } @@ -133,5 +133,9 @@ mod tests { fn root_path_falls_back_to_llms_txt() { assert_eq!(url_last_segment("https://x.io/"), "llms.txt"); assert_eq!(url_last_segment("https://x.io/a/b/guide.md"), "guide.md"); + // A NON-root trailing slash also falls back (TS `pop()` yields "" here). + // The previous `rfind(non-empty)` wrongly returned "docs". + assert_eq!(url_last_segment("https://x.io/docs/"), "llms.txt"); + assert_eq!(url_last_segment("https://x.io/a/b/"), "llms.txt"); } } From fb4a73e138eee385e380b04ea22b002b4fd0eb1a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 11:30:16 +0900 Subject: [PATCH 41/44] fix: guard copy_dir_recursive against symlink cycles and escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the directory-symlink dereferencing added in the prior commit (cubic P1 on a9f693a). Now that copy_dir_recursive follows a symlink-to-dir, an in-tree cycle (ln -s . loop) would infinite-recurse into a stack overflow and an outbound symlink (→ /) would copy arbitrary external data into the vendor dir. Thread the canonical copy root + a stack-scoped visited set through the recursion: a subdirectory is only followed when its real path stays within the root and is not already on the current descent. Seeded with the root so a self-loop is caught. Cycle + escape regression test added. --- crates/ask/src/skills/vendor.rs | 55 ++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/ask/src/skills/vendor.rs b/crates/ask/src/skills/vendor.rs index f48a3b5..796130a 100644 --- a/crates/ask/src/skills/vendor.rs +++ b/crates/ask/src/skills/vendor.rs @@ -22,6 +22,21 @@ pub struct VendorResult { /// Follows the Node `fs.cpSync(recursive)` default (regular file contents; no /// verbatim-symlink preservation — producer skills are plain trees). fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { + // Containment root: a dereferenced directory symlink must not escape the copy + // source. Seed `visited` with the root so a self-loop (`ln -s . loop`) is a + // cycle hit, not a fresh descent. + let root = std::fs::canonicalize(src).unwrap_or_else(|_| src.to_path_buf()); + let mut visited: std::collections::HashSet = std::collections::HashSet::new(); + visited.insert(root.clone()); + copy_dir_inner(&root, src, dst, &mut visited) +} + +fn copy_dir_inner( + root: &Path, + src: &Path, + dst: &Path, + visited: &mut std::collections::HashSet, +) -> std::io::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; @@ -38,7 +53,20 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { .map(|m| m.is_dir()) .unwrap_or_else(|_| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)); if is_dir { - copy_dir_recursive(&from, &to)?; + // Because directory symlinks are now followed, guard against symlink + // cycles (`ln -s . loop` → infinite recursion → stack overflow) and + // outbound escapes (a symlink to `/` would copy arbitrary external + // data into the vendor dir). Resolve the real path: skip it when it + // leaves the copy root, is already on the current descent (cycle), or + // cannot be resolved. `visited` is stack-scoped (removed after the + // subtree) so legitimate DAG repeats via distinct paths still copy. + match std::fs::canonicalize(&from) { + Ok(real) if real.starts_with(root) && visited.insert(real.clone()) => { + copy_dir_inner(root, &from, &to, visited)?; + visited.remove(&real); + } + _ => {} + } } else { // Regular files (and file symlinks) are dereferenced by std::fs::copy. std::fs::copy(&from, &to)?; @@ -177,4 +205,29 @@ mod tests { // The symlinked directory's contents are copied through. assert_eq!(std::fs::read_to_string(dst.join("link/a.md")).unwrap(), "A"); } + + // Following directory symlinks must not hang on a cycle or copy external data + // through an outbound symlink. + #[cfg(unix)] + #[test] + fn symlink_cycle_and_escape_are_guarded() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::write(src.join("a.md"), "A").unwrap(); + // Self-loop — would infinite-recurse without the cycle guard. + std::os::unix::fs::symlink(".", src.join("loop")).unwrap(); + // Outbound escape to a sibling outside the copy root. + let outside = dir.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret.md"), "SECRET").unwrap(); + std::os::unix::fs::symlink(&outside, src.join("escape")).unwrap(); + + let dst = dir.path().join("dst"); + // Must terminate (no stack overflow)… + copy_dir_recursive(&src, &dst).unwrap(); + assert_eq!(std::fs::read_to_string(dst.join("a.md")).unwrap(), "A"); + // …and the escaping symlink's target is not pulled in. + assert!(!dst.join("escape/secret.md").exists()); + } } From 5fde4a6d62edeb91f6dcb61a2874e75143359629 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 11:38:17 +0900 Subject: [PATCH 42/44] chore: add run-ask-rust skill with smoke driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .claude/skills/run-ask-rust/ — agent-facing run skill for the Rust port of the ask CLI. smoke.sh builds the release binary and drives the full user flow (add/list/fetch/src/docs/search/docs-paths/remove/npm-add/ cache ls) in an isolated temp project + temp ASK_HOME with per-step assertions; SKILL.md documents build, smoke, direct test invocation, the parity harness, and verified gotchas. --- .claude/skills/run-ask-rust/SKILL.md | 115 +++++++++++++++++++++++++++ .claude/skills/run-ask-rust/smoke.sh | 109 +++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 .claude/skills/run-ask-rust/SKILL.md create mode 100755 .claude/skills/run-ask-rust/smoke.sh diff --git a/.claude/skills/run-ask-rust/SKILL.md b/.claude/skills/run-ask-rust/SKILL.md new file mode 100644 index 0000000..b32bb8b --- /dev/null +++ b/.claude/skills/run-ask-rust/SKILL.md @@ -0,0 +1,115 @@ +--- +name: run-ask-rust +description: Build, run, test, and smoke-test the Rust port of the ask CLI (crates/ask, binary `ask`). Use when asked to run the Rust CLI, verify a command works end-to-end, run the smoke driver, run cargo tests, or check TS↔Rust parity with the differential harness. +--- + +# Run the ask CLI (Rust port) + +The Rust port of `@pleaseai/ask` lives in `crates/ask` (crate name **`ask-please`**, +binary **`ask`**, lib name `ask`). It is a plain CLI — no server, no GUI. The +agent path is the committed smoke driver, which builds the binary and drives the +full user flow (add → fetch → src/docs/search → remove) in an isolated temp +project + temp `ASK_HOME` with per-step assertions. + +All paths below are relative to the repo root (the directory with `Cargo.toml`). + +## Prerequisites + +- Rust toolchain — pinned by `rust-toolchain.toml` (1.94.1); `cargo` on PATH is enough. +- `git` on PATH (`ask fetch` shells out to `git clone --depth 1`). +- Network access for the fetch/src/docs steps (use `SMOKE_OFFLINE=1` without it). +- `bun` only for the parity harness (it builds the TS oracle), not for the Rust CLI. + +## Build + +```bash +cargo build --release --locked -p ask-please +./target/release/ask --version # → ask 0.4.8 +``` + +Takes ~70s clean (release profile has lto + codegen-units=1). For iteration, +`cargo build -p ask-please` (debug) is much faster; the parity harness uses +`target/debug/ask`. + +## Run (agent path) — smoke driver + +```bash +.claude/skills/run-ask-rust/smoke.sh # cargo build + 11-step full flow +.claude/skills/run-ask-rust/smoke.sh --no-build # reuse existing target/release/ask +SMOKE_OFFLINE=1 .claude/skills/run-ask-rust/smoke.sh --no-build # 6 steps, no network +``` + +Success ends with `SMOKE PASS (11 steps)` (6 offline). Steps covered: `--version`, +`add` (github spec → ask.json + AGENTS.md marker + `.claude/skills/-docs/SKILL.md`), +`list`, `fetch` (real shallow clone of `tj/commander.js@v12.1.0`), `src --json`, +`docs`, `search` (no-csp fallback recipe), `add --docs-paths` (object entry), +`remove`, `add npm:...` (package.json range fallback), `cache ls --json`. +Everything runs in a `mktemp -d` — nothing touches your real `~/.ask` or project. + +## Run (manual, ad-hoc) + +To poke a single command, isolate the store with `ASK_HOME` and work in a temp dir: + +```bash +export ASK_HOME=$(mktemp -d) +cd $(mktemp -d) && echo '{"name":"demo","version":"0.0.0"}' > package.json +/path/to/repo/target/release/ask add 'github:tj/commander.js@v12.1.0' +/path/to/repo/target/release/ask fetch 'github:tj/commander.js@v12.1.0' +/path/to/repo/target/release/ask docs 'github:tj/commander.js@v12.1.0' +``` + +## Direct invocation (most PRs touch internals) + +Logic lives in the lib (`crates/ask/src/`), fully unit-tested offline (HTTP via +`MockClient`, git via local bare repos). For a PR touching one module, the right +handle is the module's test filter, not the binary: + +```bash +cargo test -p ask-please # full suite (285 tests, <1s after build) +cargo test -p ask-please lockfiles:: # one module +``` + +## Parity harness (the real oracle) + +Rust unit tests share a mental model with the code; the TS build is the external +oracle. **Re-run after changing any command's file output:** + +```bash +bash scripts/parity-diff.sh # builds TS (bun) + Rust debug, 9 cases +bash scripts/parity-diff.sh --no-build # reuse dist/ + target/debug +``` + +Success ends with `ALL PARITY CASES IDENTICAL`. It byte-diffs generated files +only (never stdout — consola vs eprintln legitimately differ). Requires `bun`. + +## Gotchas + +- **Crate name ≠ binary name.** `cargo run -p ask-please -- `; the binary + is `target/{debug,release}/ask`. `cargo run -p ask` fails. +- **`ask install`/`ask add` never download docs** (lazy-first design). `ask list` + shows `Version: unresolved … (not installed — run ask install)` even right + after a successful `add` — that's normal. Actual fetching happens in the lazy + commands (`fetch`/`src`/`docs`). +- **`ask add npm:` needs the dep declared.** Version comes from + lockfile → package.json range fallback; if the package isn't in either you get + `not found in any lockfile — skipping` (exit still 0, entry still added). +- **github specs need an explicit tag ref** (`@v12.1.0`); mutable refs like + `main` are rejected by strict validation at install time. +- **Stream split:** progress/warnings go to **stderr**, data (`src --json`, + `docs`, `cache ls --json`, recipe from `search`) to **stdout**. When asserting + on progress lines, capture `2>&1`. +- **Don't pipe ask straight into `grep -q` under `set -o pipefail`** — grep + exits on first match and SIGPIPE (141) kills the pipeline intermittently. + Capture into a variable first (the smoke driver does this everywhere). +- **`ask search` without csp is exit 0** — it prints the checkout path + a + shell-quoted `csp search …` recipe instead of failing. +- **Parity harness entrypoint trap:** the TS side runs `packages/cli/dist/cli.js` + (runMain), NOT `dist/index.js` (silent no-op). Already encoded in the script. + +## Troubleshooting + +- `FAIL: npm resolve` in an older smoke run / `file exists: package.json` in an + interactive zsh → zsh noclobber; use `printf > file` from bash or `>|` in zsh. +- `error: package 'ask' not found` → use `-p ask-please`. +- Fetch hangs or fails → network/GitHub reachability; re-run with + `SMOKE_OFFLINE=1` to confirm the rest of the CLI is healthy. diff --git a/.claude/skills/run-ask-rust/smoke.sh b/.claude/skills/run-ask-rust/smoke.sh new file mode 100755 index 0000000..f999db2 --- /dev/null +++ b/.claude/skills/run-ask-rust/smoke.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# +# Smoke driver for the Rust port of the ask CLI (crates/ask, binary `ask`). +# +# Builds the release binary (unless --no-build), then drives the full user +# flow end-to-end in an ISOLATED temp project + temp ASK_HOME: +# add(github) -> list -> fetch(network clone) -> src --json -> docs -> +# search(no-csp fallback) -> add --docs-paths -> remove -> add(npm) +# Every step asserts exit code AND a key artifact/output. +# +# Usage: +# .claude/skills/run-ask-rust/smoke.sh # build + full run +# .claude/skills/run-ask-rust/smoke.sh --no-build # reuse target/release/ask +# SMOKE_OFFLINE=1 .claude/skills/run-ask-rust/smoke.sh --no-build +# # skip network steps +set -euo pipefail +# NOTE: every assertion captures output into a variable before grep. Piping +# straight into `grep -q` under pipefail intermittently kills ask with SIGPIPE +# (grep exits on first match) and fails the step even when the output matched. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +ASK="$ROOT/target/release/ask" +SPEC='github:tj/commander.js@v12.1.0' # small repo with a real docs/ dir + +build=1 +[[ "${1:-}" == "--no-build" ]] && build=0 +if [[ "$build" == 1 ]]; then + echo "==> cargo build --release --locked -p ask-please" + (cd "$ROOT" && cargo build --release --locked -p ask-please) +fi +[[ -x "$ASK" ]] || { echo "FAIL: $ASK missing — build first"; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +export ASK_HOME="$WORK/askhome" +PROJ="$WORK/proj" +mkdir -p "$PROJ" +cd "$PROJ" + +pass=0 +step() { echo "==> $1"; } +ok() { pass=$((pass + 1)); echo " ok"; } +fail() { echo " FAIL: $1"; exit 1; } + +step "version" +out="$("$ASK" --version)" +grep -Eq '^ask [0-9]' <<<"$out" || fail "version string" +ok + +step "add $SPEC" +printf '%s\n' '{"name":"demo","version":"0.0.0"}' > package.json +"$ASK" add "$SPEC" || fail "add exit" +grep -q "$SPEC" ask.json || fail "spec not in ask.json" +grep -q 'BEGIN:ask-docs-auto-generated' AGENTS.md || fail "AGENTS.md marker" +[[ -f .claude/skills/commander.js-docs/SKILL.md ]] || fail "skill file" +ok + +step "list" +out="$("$ASK" list 2>&1)" +grep -q 'commander.js' <<<"$out" || fail "list output" +ok + +if [[ "${SMOKE_OFFLINE:-0}" != 1 ]]; then + step "fetch (network: shallow git clone)" + "$ASK" fetch "$SPEC" || fail "fetch exit" + [[ -d "$ASK_HOME/github/github.com/tj/commander.js/v12.1.0" ]] || fail "checkout dir" + ok + + step "src --json" + out="$("$ASK" src "$SPEC" --json)" + grep -q '"checkoutDir"' <<<"$out" || fail "src json" + ok + + step "docs (prints candidate doc paths)" + out="$("$ASK" docs "$SPEC")" + grep -q '/commander.js/v12.1.0' <<<"$out" || fail "docs path" + ok + + step "search (csp absent -> path + recipe, exit 0)" + out="$(PATH=/usr/bin:/bin "$ASK" search "$SPEC" 'parse options' 2>&1)" + grep -q 'csp search' <<<"$out" || fail "search recipe" + ok + + step "add --docs-paths docs (object entry)" + "$ASK" add "$SPEC" --docs-paths docs || fail "add override exit" + grep -q '"docsPaths"' ask.json || fail "docsPaths not persisted" + ok +fi + +step "remove" +"$ASK" remove "$SPEC" || fail "remove exit" +grep -q '"libraries": \[\]' ask.json || fail "ask.json not emptied" +[[ ! -e .claude/skills/commander.js-docs ]] || fail "skill not removed" +ok + +step "add npm:commander (version from package.json range)" +printf '%s\n' \ + '{"name":"demo","version":"0.0.0","dependencies":{"commander":"^12.1.0"}}' \ + > package.json +out="$("$ASK" add npm:commander 2>&1)" +grep -q 'commander@\^12.1.0' <<<"$out" || fail "npm resolve" +ok + +step "cache ls --json" +out="$("$ASK" cache ls --json)" +grep -q '"askHome"' <<<"$out" || fail "cache json" +ok + +echo "SMOKE PASS ($pass steps)" From 189fa6fb0e8cc60c3cad9660edae7c89da7425f6 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 12:44:56 +0900 Subject: [PATCH 43/44] fix: dangling-symlink link cleanup + guard escaped file symlinks in vendor copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storage.rs (greptile P1): link-mode pre-creation cleanup used docs_dir.exists(), which FOLLOWS the link and returns false for a dangling symlink (left behind after `ask cache gc` deletes the store target). The stale link then survived and symlink_dir failed with a confusing EEXIST. Use symlink_metadata (examines the link itself); remove_dir_all removes the link for both live and dangling links (verified). TS's error path already uses lstat — its pre-creation check has the same gap, tracked for the TS side. - skills/vendor.rs (coderabbit): the file branch of copy_dir_inner still let an escaping file symlink (→ /etc/passwd) copy external bytes via std::fs::copy — the directory branch already guards this. Apply the same root-containment check to file symlinks (regular files are physically in-tree, so only symlinks are checked). Escape test extended to cover a file symlink. 285 tests, clippy/fmt/actionlint clean, all parity cases byte-identical. --- crates/ask/src/skills/vendor.rs | 25 +++++++++++++++++++++---- crates/ask/src/storage.rs | 9 ++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/ask/src/skills/vendor.rs b/crates/ask/src/skills/vendor.rs index 796130a..0fc5a1d 100644 --- a/crates/ask/src/skills/vendor.rs +++ b/crates/ask/src/skills/vendor.rs @@ -68,8 +68,21 @@ fn copy_dir_inner( _ => {} } } else { - // Regular files (and file symlinks) are dereferenced by std::fs::copy. - std::fs::copy(&from, &to)?; + // std::fs::copy dereferences a file symlink, so a link pointing at an + // outbound target (`→ /etc/passwd`) would copy external bytes into the + // vendor dir — the same escape the directory branch guards. A regular + // file is physically inside the tree, so only symlinks need the check. + let is_symlink = entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false); + if is_symlink { + match std::fs::canonicalize(&from) { + Ok(real) if real.starts_with(root) => { + std::fs::copy(&from, &to)?; + } + _ => {} // escapes the root or unresolvable → skip + } + } else { + std::fs::copy(&from, &to)?; + } } } Ok(()) @@ -217,17 +230,21 @@ mod tests { std::fs::write(src.join("a.md"), "A").unwrap(); // Self-loop — would infinite-recurse without the cycle guard. std::os::unix::fs::symlink(".", src.join("loop")).unwrap(); - // Outbound escape to a sibling outside the copy root. + // Outbound escape to a sibling outside the copy root (directory symlink). let outside = dir.path().join("outside"); std::fs::create_dir_all(&outside).unwrap(); std::fs::write(outside.join("secret.md"), "SECRET").unwrap(); std::os::unix::fs::symlink(&outside, src.join("escape")).unwrap(); + // Outbound FILE symlink — std::fs::copy would dereference it. + std::fs::write(outside.join("secret.txt"), "FILESECRET").unwrap(); + std::os::unix::fs::symlink(outside.join("secret.txt"), src.join("leak.md")).unwrap(); let dst = dir.path().join("dst"); // Must terminate (no stack overflow)… copy_dir_recursive(&src, &dst).unwrap(); assert_eq!(std::fs::read_to_string(dst.join("a.md")).unwrap(), "A"); - // …and the escaping symlink's target is not pulled in. + // …and neither the escaping directory nor file symlink's target is pulled in. assert!(!dst.join("escape/secret.md").exists()); + assert!(!dst.join("leak.md").exists()); } } diff --git a/crates/ask/src/storage.rs b/crates/ask/src/storage.rs index e643d3d..25fa379 100644 --- a/crates/ask/src/storage.rs +++ b/crates/ask/src/storage.rs @@ -61,7 +61,14 @@ pub fn save_docs( if options.store_mode == StoreMode::Link { if let Some(sp) = &options.store_path { let link_target = effective_store_path(sp, options.store_subpath.as_deref()); - if docs_dir.exists() { + // Use symlink_metadata (examines the link itself), not exists (which + // follows it): a DANGLING symlink — left behind when `ask cache gc` + // deletes the store target — reports exists()==false, so the cleanup + // would be skipped and the following symlink_dir would fail EEXIST with + // a confusing "Failed to create symlink". remove_dir_all removes the + // link itself (verified for both live and dangling links), so the stale + // link is cleanly replaced. + if docs_dir.symlink_metadata().is_ok() { std::fs::remove_dir_all(&docs_dir)?; } if let Some(parent) = docs_dir.parent() { From 1230770e97bd07c12fc4391b8a0835e9305db680 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 4 Jul 2026 13:00:49 +0900 Subject: [PATCH 44/44] docs(skill): drop brittle hard-coded test count from run-ask-rust runbook The exact '285 tests' note drifts every time a test is added (coderabbit). Drop the number; the runbook still tells the reader how to run the full suite. --- .claude/skills/run-ask-rust/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/run-ask-rust/SKILL.md b/.claude/skills/run-ask-rust/SKILL.md index b32bb8b..8a89c99 100644 --- a/.claude/skills/run-ask-rust/SKILL.md +++ b/.claude/skills/run-ask-rust/SKILL.md @@ -65,7 +65,7 @@ Logic lives in the lib (`crates/ask/src/`), fully unit-tested offline (HTTP via handle is the module's test filter, not the binary: ```bash -cargo test -p ask-please # full suite (285 tests, <1s after build) +cargo test -p ask-please # full suite (<1s after build) cargo test -p ask-please lockfiles:: # one module ```