diff --git a/CHANGELOG.md b/CHANGELOG.md index 02464fb1..25f6bc04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,15 @@ hand. `ArtifactType::Copilot` with stat-only enumeration over `session-state//events.jsonl`, and per-session import loops with provenance. + - `path query` now syncs before it reads, scoped to the query's own + flags: `--source claude` syncs only claude, `--id` prefixes narrow + likewise, a bare cache-wide query syncs every type, and + `--input`-only queries never touch the cache. Output is quiet + unless something was actually ingested (`synced claude: 3 new, 1 + updated`), a sync failure degrades to querying the cache as-is, + and `--no-sync` opts out. This is the piece that makes the cache + an implementation detail: a new user can run `path query` with no + setup and get their sessions. - **`toolpath-codex`** (0.6.1, extends the bump below): `session_id_from_stem` is public — the trailing UUID of a rollout filename stem (or the whole stem when it has none), the same diff --git a/CLAUDE.md b/CLAUDE.md index d657ce45..2de9f950 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,9 @@ cargo run -p path-cli -- p cache sync claude codex # only these artifact types # Inspect / analyze cargo run -p path-cli -- p render dot --input doc.json cargo run -p path-cli -- p render md --input doc.json --detail full -# Query the whole local cache with a jaq (jq) filter over wrapped steps +# Query the whole local cache with a jaq (jq) filter over wrapped steps. +# Queries auto-sync their scope first (--source claude syncs only claude; +# --input-only queries never touch the cache); --no-sync opts out. cargo run -p path-cli -- query 'map(select(.dead_end)) | map(.step.id)' cargo run -p path-cli -- query --source claude 'map(select(any(.change[].structural.token_usage; .input_tokens > 50000)))' cargo run -p path-cli -- query --input doc.json 'map(select(.step.actor | startswith("agent:")))' @@ -208,7 +210,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 348 unit + 115 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 353 unit + 119 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -278,5 +280,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. -- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (imports, parallel syncs) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when `p cache rm` evicts a doc (rm downgrades the record; the next sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when `p cache rm` evicts a doc (rm downgrades the record; the next sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. - `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 1d8287b7..7d210beb 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -217,9 +217,9 @@ fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Resul }; println!("{}", json); } else { - // `p cache sync` fills the cache under these same ids; - // re-importing an artifact whose record is still fresh is - // a no-op, not an exists-error. + // The implicit sync in `path query` fills the cache under + // these same IDs; re-importing an artifact whose record is + // still fresh is a no-op, not an exists-error. #[cfg(not(target_os = "emscripten"))] if !force && let Some(stub) = &d.provenance diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index 81c0e179..b7b7df7b 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -57,6 +57,10 @@ pub struct QueryArgs { /// reading text/diff content. Composes with `-c`. #[arg(short = 'r', long)] raw: bool, + + /// Skip the automatic cache sync that runs before the query. + #[arg(long)] + no_sync: bool, } const WRAPPER_HELP: &str = "\ @@ -89,6 +93,11 @@ Examples: path query -r '.[0].change[].structural.text' # read a turn's text, unescaped"; pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { + #[cfg(not(target_os = "emscripten"))] + if !args.no_sync { + sync_query_scope(&args); + } + let scope = Scope { source: args.source, ids: args.ids, @@ -103,3 +112,122 @@ pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { crate::query::run(&scope, &args.filter, compact, args.raw) } + +/// Freshen the slice of the cache this query will read, before reading +/// it. Quiet unless something was actually ingested; a sync failure +/// degrades to querying the cache as-is. +#[cfg(not(target_os = "emscripten"))] +fn sync_query_scope(args: &QueryArgs) { + let types = sync_types_for(args.source.as_deref(), &args.ids, &args.input); + if types.is_empty() { + return; + } + let bundle = crate::harness::HarnessBundle::from_environment(); + match crate::sync::sync_bundle(&bundle, &types, &mut ()) { + Ok(outcomes) => { + for (t, o) in outcomes { + if o.new + o.updated + o.failed > 0 { + let failed = if o.failed > 0 { + format!(", {} failed", o.failed) + } else { + String::new() + }; + eprintln!( + "synced {}: {} new, {} updated{failed}", + t.name(), + o.new, + o.updated + ); + } + } + } + Err(e) => eprintln!("warning: cache sync skipped: {e}"), + } +} + +/// Which artifact types the query's scope flags reach. `--input`-only +/// invocations never touch the cache; `--source` narrows to one type +/// (non-syncable sources like `pathbase` map to nothing); `--id`s +/// narrow to their prefixes; a bare cache-wide query syncs everything. +#[cfg(not(target_os = "emscripten"))] +fn sync_types_for( + source: Option<&str>, + ids: &[String], + inputs: &[String], +) -> Vec { + use crate::artifact::ArtifactType; + let scans_cache = source.is_some() || !ids.is_empty() || inputs.is_empty(); + if !scans_cache { + return Vec::new(); + } + if let Some(source) = source { + return ArtifactType::parse(source).into_iter().collect(); + } + if !ids.is_empty() { + return ArtifactType::ALL + .into_iter() + .filter(|t| { + ids.iter().any(|id| { + id.strip_prefix(t.name()) + .is_some_and(|rest| rest.starts_with('-')) + }) + }) + .collect(); + } + ArtifactType::ALL.to_vec() +} + +#[cfg(all(test, not(target_os = "emscripten")))] +mod tests { + use super::sync_types_for; + use crate::artifact::ArtifactType; + + fn s(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn input_only_queries_sync_nothing() { + assert!(sync_types_for(None, &[], &s(&["doc.json"])).is_empty()); + } + + #[test] + fn source_flag_narrows_to_one_type() { + assert_eq!( + sync_types_for(Some("claude"), &[], &[]), + vec![ArtifactType::Claude] + ); + assert!( + sync_types_for(Some("pathbase"), &[], &[]).is_empty(), + "non-syncable sources sync nothing" + ); + } + + #[test] + fn ids_narrow_to_their_prefixes() { + let types = sync_types_for( + None, + &s(&["claude-abc", "codex-def", "pathbase-x-y-z"]), + &[], + ); + assert_eq!(types, vec![ArtifactType::Claude, ArtifactType::Codex]); + // `cursor-…` must not match on the `c` of another type or vice versa. + assert_eq!( + sync_types_for(None, &s(&["cursor-abc"]), &[]), + vec![ArtifactType::Cursor] + ); + } + + #[test] + fn bare_cache_query_syncs_everything() { + assert_eq!(sync_types_for(None, &[], &[]), ArtifactType::ALL.to_vec()); + } + + #[test] + fn source_beats_ids_and_inputs_do_not_disable_cache_scan() { + assert_eq!( + sync_types_for(Some("git"), &s(&["claude-abc"]), &s(&["doc.json"])), + vec![ArtifactType::Git] + ); + } +} diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 85c4f710..f5c22b36 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -994,6 +994,38 @@ fn import_ingests_thinking_maximally() { ); } +#[test] +fn import_after_query_sync_is_a_noop_not_an_error() { + let (home, _session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + + // A bare query auto-syncs the session into the cache… + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["query", "--source", "claude", "length"]) + .assert() + .success(); + + // …and the documented explicit import must not die on the exists-check. + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("already up to date")); +} + #[cfg(unix)] #[test] fn bulk_import_skips_unreadable_sessions() { diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs index f32f7509..5ebbf029 100644 --- a/crates/path-cli/tests/query.rs +++ b/crates/path-cli/tests/query.rs @@ -513,3 +513,90 @@ fn kind_unknown_errors() { } // ── auto-sync on invocation ────────────────────────────────────────── + +/// A `$HOME` with one real Claude session for exercising query's +/// implicit sync (distinct from the shared empty sandbox in `cmd()`). +fn claude_home() -> tempfile::TempDir { + let home = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + let slug = project + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '_', '.'], "-"); + let dir = home.path().join(".claude/projects").join(slug); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("feedbeef-query.jsonl"), + format!( + r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}} +{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}} +"#, + cwd = project.display() + ), + ) + .unwrap(); + home +} + +fn fixture_query<'a>( + home: &tempfile::TempDir, + cfg: &Path, + args: impl IntoIterator, +) -> assert_cmd::assert::Assert { + Command::cargo_bin("path") + .unwrap() + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg) + .arg("query") + .args(args) + .assert() +} + +#[test] +fn query_syncs_its_scope_before_reading() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + // Empty cache; the query ingests the session and reads it in one go. + fixture_query(&home, cfg.path(), ["--source", "claude", "length > 0"]) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced claude: 1 new")); + assert!(cfg.path().join("manifest.json").exists()); + + // Second run: nothing changed, so the sync is silent. + fixture_query(&home, cfg.path(), ["--source", "claude", "length > 0"]) + .success() + .stdout(predicate::str::contains("true")) + .stderr(predicate::str::contains("synced").not()); +} + +#[test] +fn query_no_sync_reads_the_cache_as_is() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + fixture_query(&home, cfg.path(), ["--no-sync", "length"]) + .success() + .stdout(predicate::str::contains("0")); + assert!(!cfg.path().join("manifest.json").exists()); +} + +#[test] +fn input_only_query_never_touches_the_cache() { + let home = claude_home(); + let cfg = tempfile::tempdir().unwrap(); + let doc = cfg.path().join("doc.json"); + std::fs::write(&doc, CLAUDE_DOC).unwrap(); + Command::cargo_bin("path") + .unwrap() + .env("HOME", home.path()) + .env_remove("XDG_DATA_HOME") + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["query", "--input"]) + .arg(&doc) + .arg("length > 0") + .assert() + .success() + .stdout(predicate::str::contains("true")); + assert!(!cfg.path().join("manifest.json").exists()); +}