diff --git a/CHANGELOG.md b/CHANGELOG.md index c13fa974..13265c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,66 @@ All notable changes to the Toolpath workspace are documented here. +## One artifact-type layer and per-session imports — 2026-07-16 + +Groundwork for a cache that fills itself: one enum for artifact +sources, per-session derive plumbing in every import flow, and cheap +session-fingerprint APIs in the provider crates. + +- **`path-cli`** (0.16.0): + - `ArtifactType` (in `artifact.rs`) is the general enum naming + artifact sources; `--harness` filters and import cache-id prefixes + all use it (it absorbs the former `HarnessArg`). The `Harness` + enum stays as the harness-only layer that `share`/`resume` + `--harness` take — you can't resume into a git repo — mapping into + the general enum via `Harness::artifact_type()`. `SessionRow` + generalizes to `ArtifactRow` (its `message_count` is now optional, + for future non-session artifact kinds). + - Cross-command machinery moved out of the command modules into + modules of its own: `artifact.rs` (`ArtifactType`), `harness.rs` + (`Harness`, `HarnessBundle`, the provider not-found probes), + `cache.rs` (the document store: `write_cached`, `cache_ref`, + `list_cached`, `make_id`, …), and `derive.rs` (`DerivedDoc`, the + per-session `derive_*_session` helpers, the Pathbase fetch). + `cmd_*.rs` files hold only their command's argument surface and + flow. + - Every import flow loops per-session derive helpers + (`derive_*_session_with`, one per provider, each usable with an + injected provider manager) — explicit `--session`, picker + multi-select, `--all`, and the most-recent fallbacks — and `--all` + warns-and-skips unreadable sessions instead of aborting the batch. + **Breaking**: `p import pi --all` now emits one Path document per + session, consistent with every other provider (it previously + produced a single combined Graph). + - **Maximal ingest**: thinking blocks are always derived for claude + and gemini (**breaking**: `p import gemini` loses its + `--include-thinking` flag). Uploads, the local cache, and `resume` + all carry the full derivation. + - Claude derives leave `DeriveConfig.project_path` unset so + `path.base` comes from the session's own recorded cwd rather than + the lossy project-dir slug, falling back to the caller's + `--project` when no cwd was recorded. +- **`toolpath-gemini`** (0.6.1): new `PathResolver::list_session_entries` + returns each session's listing id, inner `sessionId`, and backing + file/dir path, and `peek_session_id` is now bounded — it scans the + first 4 KiB of a main chat file (identity fields lead the JSON) and + falls back to a full parse only when they don't (including when a + small file declines in the prefix). `list_sessions` delegates to it + unchanged. This lets callers fingerprint gemini sessions without + reading chat bodies. +- **`toolpath-claude`** (0.12.1): `ClaudeConvo::session_chain` is now + public — it resolves the full session chain for a session id (oldest + segment first), which lets callers fingerprint a conversation across + rotations without reading bodies. It rides the same cached chain + index as `list_conversations`, so calling it after a listing costs + no extra IO. +- **`toolpath-codex`** (0.6.1): new `list_session_ids` on + `CodexConvo`/`ConvoIO` lists session ids (rollout filename stems) + from a single directory walk with no file reads, and + `find_rollout_file` resolves a full stem directly through its + `YYYY/MM/DD` bucket instead of walking the tree. Together these make + enumerate-then-read (`p import codex --all`) one parse per file. + ## Derive: resolve duplicate step ids — 2026-07-01 - **`toolpath-convo`** (0.11.1): `derive_path` now guarantees the derived diff --git a/CLAUDE.md b/CLAUDE.md index b6c63e53..d75497af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,14 +199,14 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-git`: 33 unit + 3 doc tests (derive, branch detection, diffstat) - `toolpath-github`: 32 unit + 3 doc tests (mapping, DAG construction, fixtures) - `toolpath-claude`: 229 unit + 18 integration + 6 doc tests (path resolution, conversation reading, query, chaining, watcher, derive, metadata first-user-message, group_id grouping + once-per-message usage totals) -- `toolpath-gemini`: 161 unit + 29 integration + 5 doc tests (path resolution, chat-file parsing, query, watcher, derive, provider, round-trip fidelity, thoughts-folded-into-output + reasoning breakdown round-trip) -- `toolpath-codex`: 80 unit + 51 integration + 2 doc tests (rollout parsing, provider assembly, patch-fidelity derive, real-session fixture, source→path fidelity invariants, JSON wire-level round-trip, per-turn token deltas from cumulative counters, reasoning breakdown) +- `toolpath-gemini`: 165 unit + 29 integration + 5 doc tests (path resolution, chat-file parsing, query, watcher, derive, provider, round-trip fidelity, thoughts-folded-into-output + reasoning breakdown round-trip) +- `toolpath-codex`: 82 unit + 51 integration + 2 doc tests (rollout parsing, provider assembly, patch-fidelity derive, real-session fixture, source→path fidelity invariants, JSON wire-level round-trip, per-turn token deltas from cumulative counters, reasoning breakdown) - `toolpath-copilot`: 63 unit + 8 integration + 1 doc test (`events.jsonl` envelope/event-type classification incl. `session.start` nested `context` + `tool.execution` `result.content` + `assistant.message` `reasoningText`/`outputTokens`, `session.shutdown` `tokenDetails`, path resolution incl. legacy `history-session-state/`, reader malformed-line tolerance without env races, tolerant `workspace.yaml` parse, `to_view` turn/tool/delegation assembly + per-turn token usage + shutdown-total merge, id-based **and** id-less positional tool pairing, position-stable turn ids, native file-state diff from `result.detailedContent`, `CopilotProjector` round-trip + foreign-tool-name remap). Ships a **real captured feature-elicit session** at `tests/fixtures/real-session.jsonl` (also `test-fixtures/copilot/convo.jsonl`) driving `real_fixture_roundtrip.rs` (forward invariants, projection round-trip fidelity, wire-level serde value-identity). The projector is exercised by the cross-harness matrix in `path-cli`. - `toolpath-opencode`: 52 unit + 19 integration + 1 doc test (SQLite reader, JSON payload serde, provider assembly, snapshot-based derive, tool-input fallback for gitignored paths, reasoning breakdown) - `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`: 326 unit + 105 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` incl. Copilot, Copilot import/list/show/**export** via `COPILOT_HOME` + Copilot in the `path share` aggregator + Copilot in the cross-harness conformance matrix, `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). 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`: 327 unit + 107 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, and per-session import loops — maximal thinking ingest, unreadable-session skipping — over resolver-injected provider fixtures). 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` @@ -222,7 +222,7 @@ The Tauri 2 desktop GUI lives in the private [pathbase](https://github.com/empat ## Versioning and release checklist -When changing a crate's public API (new types, new trait impls, new public methods, new dependencies), bump its version. Use semver: patch for bug fixes, minor for additive changes, major for breaking changes. Pre-1.0 crates treat minor as "potentially breaking." +When changing a crate's public API (new types, new trait impls, new public methods, new dependencies), bump its version. For pre-1.0 library crates, cargo treats the **z** position of `0.y.z` as the compatible slot: bug fixes *and additive changes* bump patch (`0.6.0` → `0.6.1`, so `^0.6` consumers like pathbase-app pick them up for free); bump minor only for potentially-breaking changes. `path-cli` is the app, not a library — it bumps minor per feature. **Every version bump must update all of the following:** @@ -257,7 +257,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `PathOrRef::Path` is `Box` to avoid a large enum variant size difference - The git derivation (`toolpath-git`) uses `git2` (libgit2 bindings), not shelling out to git - Claude conversation data lives in `~/.claude/projects/` as JSONL files; `toolpath-claude` reads these directly -- `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on context overflow; `read_conversation` merges segments, `list_conversations` returns chain heads. `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental. +- `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on continuation (plan-mode exit, resume, fork; older versions on autocompact); `read_conversation` merges segments, `list_conversations` returns chain heads (the *oldest* segment of each chain — the rotation-stable id), `session_chain` resolves a chain's segments oldest-first (public since 0.12.1; it's how sync fingerprints whole chains). `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental. - Gemini CLI conversation data lives in `~/.gemini/tmp//chats/`. Main sessions sit at the top (`session--.json`, `kind: "main"`); sub-agents live in sibling `/` directories (`kind: "subagent"`). The `` slot is either a friendly name from `~/.gemini/projects.json` or the SHA-256 hex of the absolute project path; `toolpath-gemini` resolves both. - `toolpath-gemini` treats main file + sibling sub-agent UUID dir as one conversation. Sub-agent files are folded into `DelegatedWork` with populated `turns` (unlike `toolpath-claude`, whose sub-agent turns live in separate session files and stay empty). See `docs/agents/formats/gemini.md` for the full format reference. - Provider-specific extras convention: `Turn.extra` and `WatcherEvent::Progress.data` use provider-namespaced keys (e.g. `extra["claude"]`, `extra["gemini"]`). `toolpath-claude` populates `Turn.extra["claude"]` from `ConversationEntry.extra`; `toolpath-gemini` populates `Turn.extra["gemini"]` with the full `tokens` struct, per-thought metadata, and tool-call status. This lets trait-only consumers access provider metadata without importing provider types. @@ -273,6 +273,7 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. -- `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. +- `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. 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. +- `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources. It derives `clap::ValueEnum` and is used by `ArtifactRow.artifact_type` and `cmd_import`'s cache-id prefixes (`name()` is 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/Cargo.lock b/Cargo.lock index 301920f1..6cdb1e62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "assert_cmd", @@ -4073,7 +4073,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.0" +version = "0.12.1" dependencies = [ "anyhow", "chrono", @@ -4090,7 +4090,7 @@ dependencies = [ [[package]] name = "toolpath-codex" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "chrono", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "toolpath-gemini" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 835a95e6..23223581 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,9 +27,9 @@ license = "Apache-2.0" toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.12.0", path = "crates/toolpath-claude", default-features = false } -toolpath-gemini = { version = "0.6.0", path = "crates/toolpath-gemini", default-features = false } -toolpath-codex = { version = "0.6.0", path = "crates/toolpath-codex" } +toolpath-claude = { version = "0.12.1", path = "crates/toolpath-claude", default-features = false } +toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } +toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.15.0", path = "crates/path-cli" } +path-cli = { version = "0.16.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/README.md b/README.md index fdca4fc1..5d481f03 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ path git --repo PATH --branch NAME[:START] [--base COMMIT] [--remote NAME] [--title TEXT] github --repo OWNER/REPO --pr NUMBER [--no-ci] [--no-comments] claude [--project PATH] [--session ID] [--all] - gemini [--project PATH] [--session UUID] [--all] [--include-thinking] + gemini [--project PATH] [--session UUID] [--all] codex [--session UUID|STEM] [--all] opencode [--session ID] [--all] [--project ID] [--no-snapshot-diffs] pi [--project PATH] [--session ID] [--all] [--base DIR] diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 328767c2..575c972f 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.15.0" +version = "0.16.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/artifact.rs b/crates/path-cli/src/artifact.rs new file mode 100644 index 00000000..367f6ea2 --- /dev/null +++ b/crates/path-cli/src/artifact.rs @@ -0,0 +1,114 @@ +//! [`ArtifactType`], the single enum naming the artifact sources the +//! CLI operates over. + +/// The kind of artifact an operation ranges over. One enum, used +/// everywhere a command names artifact sources (`share`/`resume` +/// `--harness` via the [`Harness`](crate::harness::Harness) layer, +/// import cache-id prefixes); `name()` doubles as the cache-id prefix. +/// Github and pathbase are absent on purpose: they are remote +/// services, not local artifact sources. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] +#[value(rename_all = "lower")] +pub enum ArtifactType { + Claude, + Gemini, + Codex, + Opencode, + Cursor, + Pi, + Copilot, + Git, +} + +impl ArtifactType { + pub(crate) fn name(&self) -> &'static str { + match self { + ArtifactType::Claude => "claude", + ArtifactType::Gemini => "gemini", + ArtifactType::Codex => "codex", + ArtifactType::Opencode => "opencode", + ArtifactType::Cursor => "cursor", + ArtifactType::Pi => "pi", + ArtifactType::Copilot => "copilot", + ArtifactType::Git => "git", + } + } + + /// Width of the provider-name column in picker rows: the length of + /// the longest `name()` ("opencode"). A test asserts they stay in + /// sync. + pub(crate) const NAME_COLUMN_WIDTH: usize = 8; + + /// `name()` left-justified to [`Self::NAME_COLUMN_WIDTH`], so the + /// text after it starts at the same column on every picker row. + pub(crate) fn padded_name(&self) -> String { + format!("{: bool { + matches!( + self, + ArtifactType::Claude | ArtifactType::Gemini | ArtifactType::Pi | ArtifactType::Git + ) + } + + pub(crate) fn parse(s: &str) -> Option { + ::from_str(s, false).ok() + } +} + +#[cfg(test)] +mod type_tests { + use super::ArtifactType; + + /// Every artifact type, in presentation order. + const ALL: [ArtifactType; 8] = [ + ArtifactType::Claude, + ArtifactType::Gemini, + ArtifactType::Codex, + ArtifactType::Opencode, + ArtifactType::Cursor, + ArtifactType::Pi, + ArtifactType::Copilot, + ArtifactType::Git, + ]; + + #[test] + fn names_are_distinct() { + let names: std::collections::HashSet<&str> = ALL.iter().map(|t| t.name()).collect(); + assert_eq!(names.len(), ALL.len()); + } + + #[test] + fn name_column_width_is_the_longest_name() { + let longest = ALL.iter().map(|t| t.name().len()).max().unwrap(); + assert_eq!(ArtifactType::NAME_COLUMN_WIDTH, longest); + for t in ALL { + assert_eq!(t.padded_name().len(), ArtifactType::NAME_COLUMN_WIDTH); + } + } + + #[test] + fn path_keyed_matches_design() { + assert!(ArtifactType::Claude.path_keyed()); + assert!(ArtifactType::Gemini.path_keyed()); + assert!(ArtifactType::Pi.path_keyed()); + assert!(!ArtifactType::Codex.path_keyed()); + assert!(!ArtifactType::Opencode.path_keyed()); + assert!(!ArtifactType::Cursor.path_keyed()); + assert!(ArtifactType::Git.path_keyed()); + } + + #[test] + fn parse_roundtrips_every_name() { + for t in ALL { + assert_eq!(ArtifactType::parse(t.name()), Some(t)); + } + assert_eq!(ArtifactType::parse("frobnicate"), None); + } +} diff --git a/crates/path-cli/src/cache.rs b/crates/path-cli/src/cache.rs new file mode 100644 index 00000000..8203f67e --- /dev/null +++ b/crates/path-cli/src/cache.rs @@ -0,0 +1,314 @@ +//! On-disk cache for toolpath documents at `$CONFIG_DIR/documents/`. +//! +//! `path p import` and `path p export` both use this as the pivot +//! between external formats and toolpath JSON. Users refer to cached +//! documents by a short id (filename without `.json`) instead of full +//! paths. The `p cache ls | rm` subcommands make the directory legible. + +use anyhow::{Context, Result, anyhow, bail}; +use std::path::PathBuf; +use toolpath::v1::Graph; + +use crate::config::config_dir; + +const DOCUMENTS_DIR: &str = "documents"; + +/// An entry surfaced by `list_cached`. +#[derive(Debug, Clone)] +pub(crate) struct CacheEntry { + pub id: String, + pub path: PathBuf, + pub bytes: u64, + pub modified: std::time::SystemTime, +} + +/// The cache directory: `$CONFIG_DIR/documents/`. +pub(crate) fn cache_dir() -> Result { + Ok(config_dir()?.join(DOCUMENTS_DIR)) +} + +/// Path for a given cache id (does not check existence). +pub(crate) fn cache_path(id: &str) -> Result { + if id.is_empty() || id.contains('/') || id.contains('\\') || id.ends_with(".json") { + bail!("invalid cache id: {id:?}"); + } + Ok(cache_dir()?.join(format!("{id}.json"))) +} + +/// Write a toolpath document to the cache under `id`. Errors if the +/// file already exists unless `force` is true. +/// +/// Uses `O_CREAT | O_EXCL` (`create_new`) when `force == false` so the +/// exists-check and the write are atomic — two concurrent `path import` +/// invocations racing the same id can't silently stomp each other. +pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result { + use std::io::Write; + + let dir = cache_dir()?; + std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); + } + + let path = cache_path(id)?; + let json = doc.to_json_pretty()?; + + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).truncate(true); + if force { + opts.create(true); + } else { + opts.create_new(true); + } + + let mut file = match opts.open(&path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + bail!( + "cache entry {id} already exists at {}; pass --force to overwrite", + path.display() + ); + } + Err(e) => { + return Err(anyhow!("open {}: {e}", path.display())); + } + }; + file.write_all(json.as_bytes()) + .with_context(|| format!("write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("chmod 0600 {}", path.display()))?; + } + Ok(path) +} + +/// Resolve a `` string to a filesystem path. A ref is either a +/// bare cache id (looks up `$CACHE_DIR/.json`) or a file path +/// (contains `/` or `\\`, or ends with `.json`). +pub(crate) fn cache_ref(s: &str) -> Result { + if s.contains('/') || s.contains('\\') || s.ends_with(".json") { + let p = PathBuf::from(s); + if !p.exists() { + bail!( + "file not found: {}; if you meant a cache id, drop the path/extension and run `path cache ls`", + p.display() + ); + } + return Ok(p); + } + let p = cache_path(s)?; + if !p.exists() { + bail!( + "cache entry {s} not found at {}; run `path cache ls` to see what's cached", + p.display() + ); + } + Ok(p) +} + +pub(crate) fn list_cached() -> Result> { + let dir = cache_dir()?; + if !dir.exists() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let id = match path.file_stem().and_then(|s| s.to_str()) { + Some(s) => s.to_string(), + None => continue, + }; + let meta = entry.metadata()?; + out.push(CacheEntry { + id, + path, + bytes: meta.len(), + modified: meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH), + }); + } + out.sort_by(|a, b| b.modified.cmp(&a.modified)); + Ok(out) +} + +pub(crate) fn remove_cached(id: &str) -> Result<()> { + let path = cache_path(id)?; + if !path.exists() { + return Err(anyhow!("cache entry {id} not found")); + } + std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + Ok(()) +} + +/// Build a cache id for a given source + inner id. +/// +/// Sanitizes `/` and other filesystem-unfriendly characters in the +/// inner id to `_` so (e.g.) git branch names land cleanly. Also strips +/// a trailing `.json` so the result never collides with the cache's +/// file extension (see [`cache_path`]). +pub(crate) fn make_id(source: &str, inner: &str) -> String { + let trimmed = inner.trim_end_matches(".json"); + let safe: String = trimmed + .chars() + .map(|c| match c { + '/' | '\\' | ':' | ' ' | '\t' => '_', + c => c, + }) + .collect(); + format!("{source}-{safe}") +} + +/// The cache id a Pathbase download lands at: +/// `pathbase---`. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn pathbase_cache_id(owner: &str, repo: &str, id: &str) -> String { + make_id("pathbase", &format!("{owner}-{repo}-{id}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + + fn with_cfg R, R>(f: F) -> R { + let temp = tempfile::tempdir().unwrap(); + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path()); + } + let result = f(temp.path()); + unsafe { + std::env::remove_var(CONFIG_DIR_ENV); + } + result + } + + fn sample_doc() -> Graph { + Graph::new("g-sample") + } + + #[test] + fn write_and_read_cache_entry() { + with_cfg(|_| { + let doc = sample_doc(); + let p = write_cached("claude-abc", &doc, false).unwrap(); + assert!(p.exists()); + assert_eq!(p.file_name().unwrap(), "claude-abc.json"); + }); + } + + #[test] + fn write_errors_if_exists_without_force() { + with_cfg(|_| { + let doc = sample_doc(); + write_cached("claude-abc", &doc, false).unwrap(); + let err = write_cached("claude-abc", &doc, false).unwrap_err(); + assert!(err.to_string().contains("already exists")); + }); + } + + #[test] + fn write_force_overwrites() { + with_cfg(|_| { + let doc = sample_doc(); + write_cached("claude-abc", &doc, false).unwrap(); + write_cached("claude-abc", &doc, true).unwrap(); + }); + } + + #[test] + fn cache_ref_finds_existing_cache_entry() { + with_cfg(|_| { + let doc = sample_doc(); + let p = write_cached("claude-abc", &doc, false).unwrap(); + let resolved = cache_ref("claude-abc").unwrap(); + assert_eq!(resolved, p); + }); + } + + #[test] + fn cache_ref_returns_file_path_unchanged() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), "{}").unwrap(); + let resolved = cache_ref(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!(resolved, tmp.path()); + } + + #[test] + fn cache_ref_errors_on_missing_id() { + with_cfg(|_| { + let err = cache_ref("does-not-exist").unwrap_err(); + assert!(err.to_string().contains("not found")); + }); + } + + #[test] + fn cache_path_rejects_slashes_and_json_suffix() { + assert!(cache_path("foo/bar").is_err()); + assert!(cache_path("foo.json").is_err()); + assert!(cache_path("").is_err()); + } + + #[test] + fn list_empty_when_dir_missing() { + with_cfg(|_| { + assert!(list_cached().unwrap().is_empty()); + }); + } + + #[test] + fn list_and_remove_roundtrip() { + with_cfg(|_| { + let doc = sample_doc(); + write_cached("a", &doc, false).unwrap(); + write_cached("b", &doc, false).unwrap(); + let entries = list_cached().unwrap(); + assert_eq!(entries.len(), 2); + + remove_cached("a").unwrap(); + let entries = list_cached().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, "b"); + + assert!(remove_cached("a").is_err()); + }); + } + + #[cfg(unix)] + #[test] + fn writes_file_with_0600() { + use std::os::unix::fs::PermissionsExt; + with_cfg(|_| { + let p = write_cached("claude-abc", &sample_doc(), false).unwrap(); + let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + }); + } + + #[test] + fn make_id_sanitizes_slashes() { + assert_eq!(make_id("git", "main"), "git-main"); + assert_eq!(make_id("git", "feature/x"), "git-feature_x"); + assert_eq!(make_id("pathbase", "trc_01H"), "pathbase-trc_01H"); + } + + #[test] + fn make_id_strips_trailing_json() { + assert_eq!(make_id("pathbase", "trc_01H.json"), "pathbase-trc_01H"); + assert_eq!(make_id("git", "path-main.json"), "git-path-main"); + } + + #[test] + fn make_id_result_survives_cache_path() { + // Regression: make_id output must be accepted by cache_path. + let id = make_id("pathbase", "trc_01H.json"); + assert!(cache_path(&id).is_ok()); + } +} diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index e6a08c2d..1c5226e4 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -1,18 +1,10 @@ -//! On-disk cache for toolpath documents at `$CONFIG_DIR/documents/`. -//! -//! `path import` and `path export` both use this as the pivot between -//! external formats and toolpath JSON. Users refer to cached documents -//! by a short id (filename without `.json`) instead of full paths. -//! The `path cache ls | rm` subcommands make the directory legible. +//! `path p cache ls | rm` — make the document cache legible. The +//! store itself lives in [`crate::cache`]. -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::Result; use clap::Subcommand; -use std::path::PathBuf; -use toolpath::v1::Graph; -use crate::config::config_dir; - -const DOCUMENTS_DIR: &str = "documents"; +use crate::cache::{list_cached, remove_cached}; #[derive(Subcommand, Debug)] pub enum CacheOp { @@ -49,296 +41,3 @@ fn run_rm(id: &str) -> Result<()> { eprintln!("Removed {id}"); Ok(()) } - -/// An entry surfaced by `list_cached`. -#[derive(Debug, Clone)] -pub(crate) struct CacheEntry { - pub id: String, - pub path: PathBuf, - pub bytes: u64, - pub modified: std::time::SystemTime, -} - -/// The cache directory: `$CONFIG_DIR/documents/`. -pub(crate) fn cache_dir() -> Result { - Ok(config_dir()?.join(DOCUMENTS_DIR)) -} - -/// Path for a given cache id (does not check existence). -pub(crate) fn cache_path(id: &str) -> Result { - if id.is_empty() || id.contains('/') || id.contains('\\') || id.ends_with(".json") { - bail!("invalid cache id: {id:?}"); - } - Ok(cache_dir()?.join(format!("{id}.json"))) -} - -/// Write a toolpath document to the cache under `id`. Errors if the -/// file already exists unless `force` is true. -/// -/// Uses `O_CREAT | O_EXCL` (`create_new`) when `force == false` so the -/// exists-check and the write are atomic — two concurrent `path import` -/// invocations racing the same id can't silently stomp each other. -pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result { - use std::io::Write; - - let dir = cache_dir()?; - std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)); - } - - let path = cache_path(id)?; - let json = doc.to_json_pretty()?; - - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).truncate(true); - if force { - opts.create(true); - } else { - opts.create_new(true); - } - - let mut file = match opts.open(&path) { - Ok(f) => f, - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - bail!( - "cache entry {id} already exists at {}; pass --force to overwrite", - path.display() - ); - } - Err(e) => { - return Err(anyhow!("open {}: {e}", path.display())); - } - }; - file.write_all(json.as_bytes()) - .with_context(|| format!("write {}", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("chmod 0600 {}", path.display()))?; - } - Ok(path) -} - -/// Resolve a `` string to a filesystem path. A ref is either a -/// bare cache id (looks up `$CACHE_DIR/.json`) or a file path -/// (contains `/` or `\\`, or ends with `.json`). -pub(crate) fn cache_ref(s: &str) -> Result { - if s.contains('/') || s.contains('\\') || s.ends_with(".json") { - let p = PathBuf::from(s); - if !p.exists() { - bail!( - "file not found: {}; if you meant a cache id, drop the path/extension and run `path cache ls`", - p.display() - ); - } - return Ok(p); - } - let p = cache_path(s)?; - if !p.exists() { - bail!( - "cache entry {s} not found at {}; run `path cache ls` to see what's cached", - p.display() - ); - } - Ok(p) -} - -pub(crate) fn list_cached() -> Result> { - let dir = cache_dir()?; - if !dir.exists() { - return Ok(Vec::new()); - } - let mut out = Vec::new(); - for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) != Some("json") { - continue; - } - let id = match path.file_stem().and_then(|s| s.to_str()) { - Some(s) => s.to_string(), - None => continue, - }; - let meta = entry.metadata()?; - out.push(CacheEntry { - id, - path, - bytes: meta.len(), - modified: meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH), - }); - } - out.sort_by(|a, b| b.modified.cmp(&a.modified)); - Ok(out) -} - -pub(crate) fn remove_cached(id: &str) -> Result<()> { - let path = cache_path(id)?; - if !path.exists() { - return Err(anyhow!("cache entry {id} not found")); - } - std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; - Ok(()) -} - -/// Build a cache id for a given source + inner id. -/// -/// Sanitizes `/` and other filesystem-unfriendly characters in the -/// inner id to `_` so (e.g.) git branch names land cleanly. Also strips -/// a trailing `.json` so the result never collides with the cache's -/// file extension (see [`cache_path`]). -pub(crate) fn make_id(source: &str, inner: &str) -> String { - let trimmed = inner.trim_end_matches(".json"); - let safe: String = trimmed - .chars() - .map(|c| match c { - '/' | '\\' | ':' | ' ' | '\t' => '_', - c => c, - }) - .collect(); - format!("{source}-{safe}") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; - - fn with_cfg R, R>(f: F) -> R { - let temp = tempfile::tempdir().unwrap(); - let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - unsafe { - std::env::set_var(CONFIG_DIR_ENV, temp.path()); - } - let result = f(temp.path()); - unsafe { - std::env::remove_var(CONFIG_DIR_ENV); - } - result - } - - fn sample_doc() -> Graph { - Graph::new("g-sample") - } - - #[test] - fn write_and_read_cache_entry() { - with_cfg(|_| { - let doc = sample_doc(); - let p = write_cached("claude-abc", &doc, false).unwrap(); - assert!(p.exists()); - assert_eq!(p.file_name().unwrap(), "claude-abc.json"); - }); - } - - #[test] - fn write_errors_if_exists_without_force() { - with_cfg(|_| { - let doc = sample_doc(); - write_cached("claude-abc", &doc, false).unwrap(); - let err = write_cached("claude-abc", &doc, false).unwrap_err(); - assert!(err.to_string().contains("already exists")); - }); - } - - #[test] - fn write_force_overwrites() { - with_cfg(|_| { - let doc = sample_doc(); - write_cached("claude-abc", &doc, false).unwrap(); - write_cached("claude-abc", &doc, true).unwrap(); - }); - } - - #[test] - fn cache_ref_finds_existing_cache_entry() { - with_cfg(|_| { - let doc = sample_doc(); - let p = write_cached("claude-abc", &doc, false).unwrap(); - let resolved = cache_ref("claude-abc").unwrap(); - assert_eq!(resolved, p); - }); - } - - #[test] - fn cache_ref_returns_file_path_unchanged() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write(tmp.path(), "{}").unwrap(); - let resolved = cache_ref(tmp.path().to_str().unwrap()).unwrap(); - assert_eq!(resolved, tmp.path()); - } - - #[test] - fn cache_ref_errors_on_missing_id() { - with_cfg(|_| { - let err = cache_ref("does-not-exist").unwrap_err(); - assert!(err.to_string().contains("not found")); - }); - } - - #[test] - fn cache_path_rejects_slashes_and_json_suffix() { - assert!(cache_path("foo/bar").is_err()); - assert!(cache_path("foo.json").is_err()); - assert!(cache_path("").is_err()); - } - - #[test] - fn list_empty_when_dir_missing() { - with_cfg(|_| { - assert!(list_cached().unwrap().is_empty()); - }); - } - - #[test] - fn list_and_remove_roundtrip() { - with_cfg(|_| { - let doc = sample_doc(); - write_cached("a", &doc, false).unwrap(); - write_cached("b", &doc, false).unwrap(); - let entries = list_cached().unwrap(); - assert_eq!(entries.len(), 2); - - remove_cached("a").unwrap(); - let entries = list_cached().unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].id, "b"); - - assert!(remove_cached("a").is_err()); - }); - } - - #[cfg(unix)] - #[test] - fn writes_file_with_0600() { - use std::os::unix::fs::PermissionsExt; - with_cfg(|_| { - let p = write_cached("claude-abc", &sample_doc(), false).unwrap(); - let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - }); - } - - #[test] - fn make_id_sanitizes_slashes() { - assert_eq!(make_id("git", "main"), "git-main"); - assert_eq!(make_id("git", "feature/x"), "git-feature_x"); - assert_eq!(make_id("pathbase", "trc_01H"), "pathbase-trc_01H"); - } - - #[test] - fn make_id_strips_trailing_json() { - assert_eq!(make_id("pathbase", "trc_01H.json"), "pathbase-trc_01H"); - assert_eq!(make_id("git", "path-main.json"), "git-path-main"); - } - - #[test] - fn make_id_result_survives_cache_path() { - // Regression: make_id output must be accepted by cache_path. - let id = make_id("pathbase", "trc_01H.json"); - assert!(cache_path(&id).is_ok()); - } -} diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index dfc82d2b..b1a4a5cf 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -23,7 +23,7 @@ use clap::Subcommand; use std::path::PathBuf; #[cfg(not(target_os = "emscripten"))] -use crate::cmd_cache::cache_ref; +use crate::cache::cache_ref; #[derive(Subcommand, Debug)] pub enum ExportTarget { diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 48ee8ad2..0c952340 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -14,7 +14,17 @@ use clap::Subcommand; use std::path::PathBuf; use toolpath::v1::Graph; -use crate::cmd_cache::{make_id, write_cached}; +#[cfg(not(target_os = "emscripten"))] +use crate::artifact::ArtifactType; +#[cfg(not(target_os = "emscripten"))] +use crate::cache::make_id; +use crate::cache::write_cached; +use crate::derive::{ + DerivedDoc, derive_claude_session_with, derive_codex_session_with, derive_copilot_session_with, + derive_gemini_session_with, derive_pi_session_with, +}; +#[cfg(not(target_os = "emscripten"))] +use crate::derive::{derive_cursor_session_with, derive_opencode_session_with, doc_inner_id}; #[derive(Subcommand, Debug)] pub enum ImportSource { @@ -89,10 +99,6 @@ pub enum ImportSource { /// Process all sessions in the project #[arg(long)] all: bool, - - /// Include thinking blocks in conversation.append text - #[arg(long)] - include_thinking: bool, }, /// Import from Codex CLI rollout files Codex { @@ -159,7 +165,7 @@ pub enum ImportSource { #[arg(short, long)] session: Option, - /// Process all sessions in the project (emits a Graph) + /// Process all sessions in the project #[arg(long)] all: bool, @@ -198,11 +204,6 @@ pub fn run(args: ImportArgs, pretty: bool) -> Result<()> { emit(&docs, args.force, args.no_cache, pretty) } -pub(crate) struct DerivedDoc { - pub(crate) cache_id: String, - pub(crate) doc: Graph, -} - fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Result<()> { if docs.is_empty() { anyhow::bail!("no documents produced"); @@ -258,8 +259,7 @@ fn derive(source: ImportSource) -> Result> { project, session, all, - include_thinking, - } => derive_gemini(project, session, all, include_thinking), + } => derive_gemini(project, session, all), ImportSource::Codex { session, all } => derive_codex(session, all), ImportSource::Copilot { session, all } => derive_copilot(session, all), ImportSource::Opencode { @@ -323,7 +323,7 @@ fn derive_git( let canonical = std::fs::canonicalize(&repo_path).unwrap_or(repo_path.clone()); let repo_tag = short_path_hash(&canonical.to_string_lossy()); let inner = doc_inner_id(&doc); - let cache_id = make_id("git", &format!("{repo_tag}-{inner}")); + let cache_id = make_id(ArtifactType::Git.name(), &format!("{repo_tag}-{inner}")); Ok(vec![DerivedDoc { cache_id, doc }]) } } @@ -337,11 +337,6 @@ fn short_path_hash(s: &str) -> String { format!("{:08x}", h.finish() as u32) } -/// Extract the inner identifier from a graph (without source prefix). -fn doc_inner_id(doc: &Graph) -> String { - doc.graph.id.clone() -} - fn derive_github( url: Option, repo: Option, @@ -405,11 +400,6 @@ fn derive_claude_with_manager( session: Option, all: bool, ) -> Result> { - let make_config = |p: &str| toolpath_claude::derive::DeriveConfig { - project_path: Some(p.to_string()), - include_thinking: false, - }; - // Interactive picker fires only when no explicit `--session` (and not // `--all`); the same flow handles single- and multi-select. If fzf isn't // available, we fall back to most-recent for explicit-project, or print @@ -417,11 +407,17 @@ fn derive_claude_with_manager( let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let convos = manager - .read_all_conversations(&p) + let heads = manager + .list_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(&p); - return wrap_paths_claude(toolpath_claude::derive::derive_project(&convos, &cfg)); + let mut docs = Vec::with_capacity(heads.len()); + for head in &heads { + match derive_claude_session_with(manager, &p, head) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {head}: {e}"), + } + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -435,10 +431,11 @@ fn derive_claude_with_manager( .ok_or_else(|| { anyhow::anyhow!("No conversations found for project: {}", p) })?; - let cfg = make_config(&p); - return wrap_paths_claude(vec![toolpath_claude::derive::derive_path( - &convo, &cfg, - )]); + return Ok(vec![derive_claude_session_with( + manager, + &p, + &convo.session_id, + )?]); } } #[cfg(target_os = "emscripten")] @@ -447,8 +444,11 @@ fn derive_claude_with_manager( .most_recent_conversation(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; - let cfg = make_config(&p); - return wrap_paths_claude(vec![toolpath_claude::derive::derive_path(&convo, &cfg)]); + return Ok(vec![derive_claude_session_with( + manager, + &p, + &convo.session_id, + )?]); } } (None, _, _) => { @@ -469,48 +469,15 @@ fn derive_claude_with_manager( } }; - let mut paths: Vec = Vec::with_capacity(pairs.len()); + let mut docs = Vec::with_capacity(pairs.len()); for (project_path, session_id) in &pairs { - let convo = manager - .read_conversation(project_path, session_id) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(project_path); - paths.push(toolpath_claude::derive::derive_path(&convo, &cfg)); + docs.push(derive_claude_session_with( + manager, + project_path, + session_id, + )?); } - wrap_paths_claude(paths) -} - -/// Derive a single Claude conversation given an explicit project + session. -/// Used by `cmd_share` after its picker has resolved the pair; mirrors the -/// `(Some(p), Some(s), _)` arm in [`derive_claude_with_manager`]. -pub(crate) fn derive_claude_session(project: &str, session: &str) -> Result { - let manager = toolpath_claude::ClaudeConvo::new(); - let cfg = toolpath_claude::derive::DeriveConfig { - project_path: Some(project.to_string()), - include_thinking: false, - }; - let convo = manager - .read_conversation(project, session) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let path = toolpath_claude::derive::derive_path(&convo, &cfg); - let cache_id = make_id("claude", &path.path.id); - Ok(DerivedDoc { - cache_id, - doc: Graph::from_path(path), - }) -} - -fn wrap_paths_claude(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("claude", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) + Ok(docs) } #[cfg(not(target_os = "emscripten"))] @@ -619,10 +586,9 @@ fn derive_gemini( project: Option, session: Option, all: bool, - include_thinking: bool, ) -> Result> { let manager = toolpath_gemini::GeminiConvo::new(); - derive_gemini_with_manager(&manager, project, session, all, include_thinking) + derive_gemini_with_manager(&manager, project, session, all) } fn derive_gemini_with_manager( @@ -630,21 +596,21 @@ fn derive_gemini_with_manager( project: Option, session: Option, all: bool, - include_thinking: bool, ) -> Result> { - let make_config = |p: &str| toolpath_gemini::derive::DeriveConfig { - project_path: Some(p.to_string()), - include_thinking, - }; - let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let convos = manager - .read_all_conversations(&p) + let ids = manager + .list_conversations(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(&p); - return wrap_paths_gemini(toolpath_gemini::derive::derive_project(&convos, &cfg)); + let mut docs = Vec::with_capacity(ids.len()); + for id in &ids { + match derive_gemini_session_with(manager, &p, id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {id}: {e}"), + } + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -658,10 +624,11 @@ fn derive_gemini_with_manager( .ok_or_else(|| { anyhow::anyhow!("No conversations found for project: {}", p) })?; - let cfg = make_config(&p); - return wrap_paths_gemini(vec![toolpath_gemini::derive::derive_path( - &convo, &cfg, - )]); + return Ok(vec![derive_gemini_session_with( + manager, + &p, + &convo.session_uuid, + )?]); } } #[cfg(target_os = "emscripten")] @@ -670,8 +637,11 @@ fn derive_gemini_with_manager( .most_recent_conversation(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", p))?; - let cfg = make_config(&p); - return wrap_paths_gemini(vec![toolpath_gemini::derive::derive_path(&convo, &cfg)]); + return Ok(vec![derive_gemini_session_with( + manager, + &p, + &convo.session_uuid, + )?]); } } (None, _, _) => { @@ -692,50 +662,15 @@ fn derive_gemini_with_manager( } }; - let mut paths: Vec = Vec::with_capacity(pairs.len()); + let mut docs = Vec::with_capacity(pairs.len()); for (project_path, session_uuid) in &pairs { - let convo = manager - .read_conversation(project_path, session_uuid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = make_config(project_path); - paths.push(toolpath_gemini::derive::derive_path(&convo, &cfg)); + docs.push(derive_gemini_session_with( + manager, + project_path, + session_uuid, + )?); } - wrap_paths_gemini(paths) -} - -/// Derive a single Gemini conversation given an explicit project + session. -pub(crate) fn derive_gemini_session( - project: &str, - session: &str, - include_thinking: bool, -) -> Result { - let manager = toolpath_gemini::GeminiConvo::new(); - let cfg = toolpath_gemini::derive::DeriveConfig { - project_path: Some(project.to_string()), - include_thinking, - }; - let convo = manager - .read_conversation(project, session) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let path = toolpath_gemini::derive::derive_path(&convo, &cfg); - let cache_id = make_id("gemini", &path.path.id); - Ok(DerivedDoc { - cache_id, - doc: Graph::from_path(path), - }) -} - -fn wrap_paths_gemini(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("gemini", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) + Ok(docs) } #[cfg(not(target_os = "emscripten"))] @@ -840,18 +775,24 @@ fn pick_gemini_global( fn derive_codex(session: Option, all: bool) -> Result> { let manager = toolpath_codex::CodexConvo::new(); - let config = toolpath_codex::derive::DeriveConfig { project_path: None }; let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], (None, true) => { - let sessions = manager - .read_all_sessions() + let ids = manager + .list_session_ids() .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if ids.is_empty() { anyhow::bail!("No Codex sessions found in ~/.codex/sessions"); } - return wrap_paths_codex(toolpath_codex::derive::derive_project(&sessions, &config)); + let mut docs = Vec::with_capacity(ids.len()); + for id in &ids { + match derive_codex_session_with(&manager, id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {id}: {e}"), + } + } + return Ok(docs); } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -865,9 +806,7 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths_codex(vec![toolpath_codex::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_codex_session_with(&manager, &s.id)?]); } } } @@ -879,47 +818,16 @@ fn derive_codex(session: Option, all: bool) -> Result> { .ok_or_else(|| { anyhow::anyhow!("No Codex sessions found in ~/.codex/sessions") })?; - return wrap_paths_codex(vec![toolpath_codex::derive::derive_path(&s, &config)]); + return Ok(vec![derive_codex_session_with(&manager, &s.id)?]); } } }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - paths.push(toolpath_codex::derive::derive_path(&s, &config)); + docs.push(derive_codex_session_with(&manager, sid)?); } - wrap_paths_codex(paths) -} - -/// Derive a single Codex session given an explicit session id. -pub(crate) fn derive_codex_session(session: &str) -> Result { - let manager = toolpath_codex::CodexConvo::new(); - let config = toolpath_codex::derive::DeriveConfig { project_path: None }; - let s = manager - .read_session(session) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let path = toolpath_codex::derive::derive_path(&s, &config); - let cache_id = make_id("codex", &path.path.id); - Ok(DerivedDoc { - cache_id, - doc: Graph::from_path(path), - }) -} - -fn wrap_paths_codex(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("codex", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) + Ok(docs) } #[cfg(not(target_os = "emscripten"))] @@ -968,20 +876,24 @@ fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result fn derive_copilot(session: Option, all: bool) -> Result> { let manager = toolpath_copilot::CopilotConvo::new(); - let config = toolpath_copilot::derive::DeriveConfig { project_path: None }; let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], (None, true) => { - let sessions = manager - .read_all_sessions() + let metas = manager + .list_sessions() .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if metas.is_empty() { anyhow::bail!("No Copilot sessions found in ~/.copilot/session-state"); } - return wrap_paths_copilot(toolpath_copilot::derive::derive_project( - &sessions, &config, - )); + let mut docs = Vec::with_capacity(metas.len()); + for m in &metas { + match derive_copilot_session_with(&manager, &m.id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } + } + return Ok(docs); } (None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -997,9 +909,7 @@ fn derive_copilot(session: Option, all: bool) -> Result> "No Copilot sessions found in ~/.copilot/session-state" ) })?; - return wrap_paths_copilot(vec![toolpath_copilot::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_copilot_session_with(&manager, &s.id)?]); } } } @@ -1011,49 +921,16 @@ fn derive_copilot(session: Option, all: bool) -> Result> .ok_or_else(|| { anyhow::anyhow!("No Copilot sessions found in ~/.copilot/session-state") })?; - return wrap_paths_copilot(vec![toolpath_copilot::derive::derive_path( - &s, &config, - )]); + return Ok(vec![derive_copilot_session_with(&manager, &s.id)?]); } } }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - paths.push(toolpath_copilot::derive::derive_path(&s, &config)); + docs.push(derive_copilot_session_with(&manager, sid)?); } - wrap_paths_copilot(paths) -} - -/// Derive a single Copilot session given an explicit session id. -pub(crate) fn derive_copilot_session(session: &str) -> Result { - let manager = toolpath_copilot::CopilotConvo::new(); - let config = toolpath_copilot::derive::DeriveConfig { project_path: None }; - let s = manager - .read_session(session) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let path = toolpath_copilot::derive::derive_path(&s, &config); - let cache_id = make_id("copilot", &path.path.id); - Ok(DerivedDoc { - cache_id, - doc: Graph::from_path(path), - }) -} - -fn wrap_paths_copilot(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("copilot", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) + Ok(docs) } #[cfg(not(target_os = "emscripten"))] @@ -1117,20 +994,7 @@ fn derive_opencode( #[cfg(not(target_os = "emscripten"))] { let manager = toolpath_opencode::OpencodeConvo::new(); - let config = toolpath_opencode::derive::DeriveConfig { - no_snapshot_diffs, - ..Default::default() - }; - let derive_one = |sid: &str| -> Result { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - Ok(toolpath_opencode::derive::derive_path_with_resolver( - &s, - &config, - manager.resolver(), - )) - }; + let derive_one = |sid: &str| derive_opencode_session_with(&manager, sid, no_snapshot_diffs); let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], @@ -1144,9 +1008,12 @@ fn derive_opencode( } let mut out = Vec::with_capacity(metas.len()); for m in &metas { - out.push(derive_one(&m.id)?); + match derive_one(&m.id) { + Ok(doc) => out.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } } - return wrap_paths_opencode(out); + return Ok(out); } (None, false) => match pick_opencode(&manager, project.as_deref())? { Some(picks) => picks, @@ -1155,61 +1022,19 @@ fn derive_opencode( .most_recent_session() .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No opencode sessions found"))?; - return wrap_paths_opencode(vec![ - toolpath_opencode::derive::derive_path_with_resolver( - &s, - &config, - manager.resolver(), - ), - ]); + return Ok(vec![derive_one(&s.id)?]); } }, }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - paths.push(derive_one(sid)?); + docs.push(derive_one(sid)?); } - wrap_paths_opencode(paths) + Ok(docs) } } -/// Derive a single opencode session given an explicit session id. -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn derive_opencode_session( - session: &str, - no_snapshot_diffs: bool, -) -> Result { - let manager = toolpath_opencode::OpencodeConvo::new(); - let config = toolpath_opencode::derive::DeriveConfig { - no_snapshot_diffs, - ..Default::default() - }; - let s = manager - .read_session(session) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let path = - toolpath_opencode::derive::derive_path_with_resolver(&s, &config, manager.resolver()); - let cache_id = make_id("opencode", &path.path.id); - Ok(DerivedDoc { - cache_id, - doc: Graph::from_path(path), - }) -} - -fn wrap_paths_opencode(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("opencode", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_opencode( manager: &toolpath_opencode::OpencodeConvo, @@ -1281,13 +1106,7 @@ fn derive_cursor( #[cfg(not(target_os = "emscripten"))] { let manager = toolpath_cursor::CursorConvo::new(); - let derive_one = |sid: &str| -> Result { - let s = manager - .read_session(sid) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = toolpath_cursor::DeriveConfig::default(); - Ok(toolpath_cursor::derive_path(&s, &cfg)) - }; + let derive_one = |sid: &str| derive_cursor_session_with(&manager, sid); let workspace_filter = project .as_deref() @@ -1316,9 +1135,12 @@ fn derive_cursor( } let mut out = Vec::with_capacity(filtered.len()); for m in &filtered { - out.push(derive_one(&m.id)?); + match derive_one(&m.id) { + Ok(doc) => out.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } } - return wrap_paths_cursor(out); + return Ok(out); } (None, false) => match pick_cursor(&manager, workspace_filter.as_deref())? { Some(picks) => picks, @@ -1337,48 +1159,19 @@ fn derive_cursor( .unwrap_or_else(chrono::DateTime::::default) }) .ok_or_else(|| anyhow::anyhow!("No Cursor composers found"))?; - return wrap_paths_cursor(vec![derive_one(&pick.id)?]); + return Ok(vec![derive_one(&pick.id)?]); } }, }; - let mut paths: Vec = Vec::with_capacity(session_ids.len()); + let mut docs = Vec::with_capacity(session_ids.len()); for sid in &session_ids { - paths.push(derive_one(sid)?); + docs.push(derive_one(sid)?); } - wrap_paths_cursor(paths) + Ok(docs) } } -/// Derive a single cursor composer given an explicit composer id. -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn derive_cursor_session(session: &str) -> Result { - let manager = toolpath_cursor::CursorConvo::new(); - let s = manager - .read_session(session) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let cfg = toolpath_cursor::DeriveConfig::default(); - let path = toolpath_cursor::derive_path(&s, &cfg); - let cache_id = make_id("cursor", &path.path.id); - Ok(DerivedDoc { - cache_id, - doc: Graph::from_path(path), - }) -} - -fn wrap_paths_cursor(paths: Vec) -> Result> { - Ok(paths - .into_iter() - .map(|p| { - let cache_id = make_id("cursor", &p.path.id); - DerivedDoc { - cache_id, - doc: Graph::from_path(p), - } - }) - .collect()) -} - #[cfg(not(target_os = "emscripten"))] fn pick_cursor( manager: &toolpath_cursor::CursorConvo, @@ -1469,20 +1262,23 @@ fn derive_pi_with_manager( session: Option, all: bool, ) -> Result> { - let config = toolpath_pi::DeriveConfig::default(); - let pairs: Vec<(String, String)> = match (project, session, all) { (Some(p), Some(s), _) => vec![(p, s)], (Some(p), None, true) => { - let sessions = manager - .read_all_sessions(&p) + let metas = manager + .list_sessions(&p) .map_err(|e| anyhow::anyhow!("{}", e))?; - if sessions.is_empty() { + if metas.is_empty() { anyhow::bail!("No Pi sessions found for project: {}", p); } - let doc = toolpath_pi::derive::derive_graph(&sessions, None, &config); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + let mut docs = Vec::with_capacity(metas.len()); + for m in &metas { + match derive_pi_session_with(manager, &p, &m.id) { + Ok(doc) => docs.push(doc), + Err(e) => eprintln!("Warning: skipping session {}: {e}", m.id), + } + } + return Ok(docs); } (Some(p), None, false) => { #[cfg(not(target_os = "emscripten"))] @@ -1496,9 +1292,11 @@ fn derive_pi_with_manager( .ok_or_else(|| { anyhow::anyhow!("No Pi sessions found for project: {}", p) })?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![derive_pi_session_with( + manager, + &p, + &session.header.id, + )?]); } } #[cfg(target_os = "emscripten")] @@ -1507,9 +1305,11 @@ fn derive_pi_with_manager( .most_recent_session(&p) .map_err(|e| anyhow::anyhow!("{}", e))? .ok_or_else(|| anyhow::anyhow!("No Pi sessions found for project: {}", p))?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - return Ok(vec![DerivedDoc { cache_id, doc }]); + return Ok(vec![derive_pi_session_with( + manager, + &p, + &session.header.id, + )?]); } } (None, _, _) => { @@ -1532,37 +1332,11 @@ fn derive_pi_with_manager( let mut docs: Vec = Vec::with_capacity(pairs.len()); for (project_path, session_id) in &pairs { - let session = manager - .read_session(project_path, session_id) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - docs.push(DerivedDoc { cache_id, doc }); + docs.push(derive_pi_session_with(manager, project_path, session_id)?); } Ok(docs) } -/// Derive a single Pi session given an explicit project + session. -pub(crate) fn derive_pi_session( - project: &str, - session: &str, - base: Option, -) -> Result { - let manager = if let Some(path) = base { - let resolver = toolpath_pi::PathResolver::new().with_sessions_dir(&path); - toolpath_pi::PiConvo::with_resolver(resolver) - } else { - toolpath_pi::PiConvo::new() - }; - let config = toolpath_pi::DeriveConfig::default(); - let session = manager - .read_session(project, session) - .map_err(|e| anyhow::anyhow!("{}", e))?; - let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); - let cache_id = make_id("pi", &doc_inner_id(&doc)); - Ok(DerivedDoc { cache_id, doc }) -} - #[cfg(not(target_os = "emscripten"))] fn pick_pi_in_project( manager: &toolpath_pi::PiConvo, @@ -1706,39 +1480,6 @@ fn parse_rfc3339(s: &str) -> Option> { .map(|t| t.with_timezone(&chrono::Utc)) } -/// Compute the local cache id a Pathbase ref would land at, without -/// hitting the network. Lets `path resume` probe the cache before -/// deciding whether to fetch. -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn pathbase_cache_id_of(target: &str, url_flag: Option<&str>) -> Result { - let (_base, ref_) = parse_pathbase_ref(target, url_flag)?; - let PathRef { owner, repo, id } = ref_; - Ok(make_id("pathbase", &format!("{owner}-{repo}-{id}"))) -} - -/// Fetch a Pathbase ref (`https://host/u/owner/repos/repo/graphs/` -/// URL or bare `owner/repo/` triple) and parse it as a toolpath -/// document. Used by `path import pathbase` and `path resume `. -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result { - use crate::cmd_pathbase::{credentials_path, graphs_download, load_session, resolve_url}; - - let (base, ref_) = parse_pathbase_ref(target, url_flag)?; - let stored = load_session(&credentials_path()?)?; - let base_url = base - .or_else(|| stored.as_ref().map(|s| s.url.clone())) - .unwrap_or_else(|| resolve_url(None)); - - let token = stored.as_ref().map(|s| s.token.as_str()); - - let PathRef { owner, repo, id } = ref_; - let body = graphs_download(&base_url, token, &owner, &repo, &id)?; - let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{id}")); - let doc = Graph::from_json(&body) - .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; - Ok(DerivedDoc { cache_id, doc }) -} - fn derive_pathbase(target: String, url_flag: Option) -> Result> { #[cfg(target_os = "emscripten")] { @@ -1748,210 +1489,17 @@ fn derive_pathbase(target: String, url_flag: Option) -> Result/repos//graphs/` — -/// host overrides the server URL. Also accepts the older -/// `https://host///{paths|graphs}/` shape and the -/// short `https://host///` form. -/// - `//` — bare triple, used with `--url` or the -/// stored session. -#[cfg(not(target_os = "emscripten"))] -fn parse_pathbase_ref(target: &str, url_flag: Option<&str>) -> Result<(Option, PathRef)> { - use crate::cmd_pathbase::resolve_url; - - let scheme = if target.starts_with("https://") { - Some("https://") - } else if target.starts_with("http://") { - Some("http://") - } else { - None - }; - - if let Some(scheme) = scheme { - let rest = &target[scheme.len()..]; - let (host, path) = match rest.split_once('/') { - Some((h, p)) => (h, p), - None => anyhow::bail!("URL has no path segments: {target}"), - }; - if host.is_empty() { - anyhow::bail!("URL is missing a host: {target}"); - } - let path = path.split(['?', '#']).next().unwrap_or(""); - let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - let triple = extract_triple(&segs).ok_or_else(|| { - anyhow::anyhow!("expected URL ending in ///graphs/ (got {target})") - })?; - Ok((Some(format!("{scheme}{host}")), triple)) - } else { - let base = url_flag.map(|u| resolve_url(Some(u.to_string()))); - let segs: Vec<&str> = target.split('/').filter(|s| !s.is_empty()).collect(); - let triple = extract_triple(&segs) - .ok_or_else(|| anyhow::anyhow!("expected `//`, got `{target}`"))?; - Ok((base, triple)) + Ok(vec![crate::derive::pathbase_fetch_to_doc( + &target, + url_flag.as_deref(), + )?]) } } -/// Pull (owner, repo, uuid) from a slash-split URL path. Accepts all of: -/// -/// - `/u//repos//graphs/` — current canonical form. -/// - `///{paths|graphs}/` — short SvelteKit route. -/// - `///` — bare triple. -/// -/// The trailing segment must parse as a UUID (the only addressing -/// scheme Pathbase 1.1+ accepts for graphs). -#[cfg(not(target_os = "emscripten"))] -fn extract_triple(segs: &[&str]) -> Option { - let n = segs.len(); - if n < 3 { - return None; - } - let id = segs[n - 1]; - if uuid::Uuid::parse_str(id).is_err() { - return None; - } - - // Look back through the canonical layouts in order of specificity. - let (owner, repo) = - if n >= 6 && segs[n - 6] == "u" && segs[n - 4] == "repos" && segs[n - 2] == "graphs" { - (segs[n - 5], segs[n - 3]) - } else if n >= 4 && (segs[n - 2] == "paths" || segs[n - 2] == "graphs") { - (segs[n - 4], segs[n - 3]) - } else { - (segs[n - 3], segs[n - 2]) - }; - - if owner.is_empty() || repo.is_empty() { - return None; - } - Some(PathRef { - owner: owner.to_string(), - repo: repo.to_string(), - id: id.to_string(), - }) -} - #[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; - const UUID: &str = "fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537"; - - #[test] - fn parse_pathbase_ref_full_url_canonical_form() { - let url = format!("https://pathbase.dev/u/alex/repos/pathstash/graphs/{UUID}"); - let (base, ref_) = parse_pathbase_ref(&url, None).unwrap(); - assert_eq!(base.as_deref(), Some("https://pathbase.dev")); - assert_eq!( - ref_, - PathRef { - owner: "alex".into(), - repo: "pathstash".into(), - id: UUID.into(), - } - ); - } - - #[test] - fn parse_pathbase_ref_bare_triple_with_url_flag() { - let target = format!("alex/pathstash/{UUID}"); - let (base, ref_) = parse_pathbase_ref(&target, Some("https://other.example/")).unwrap(); - assert_eq!(base.as_deref(), Some("https://other.example")); - assert_eq!( - ref_, - PathRef { - owner: "alex".into(), - repo: "pathstash".into(), - id: UUID.into(), - } - ); - } - - #[test] - fn parse_pathbase_ref_bare_triple_no_flag() { - let target = format!("alex/pathstash/{UUID}"); - let (base, ref_) = parse_pathbase_ref(&target, None).unwrap(); - assert_eq!(base, None); - assert_eq!( - ref_, - PathRef { - owner: "alex".into(), - repo: "pathstash".into(), - id: UUID.into(), - } - ); - } - - #[test] - fn parse_pathbase_ref_url_with_trailing_slash() { - let url = format!("https://pathbase.dev/alex/pathstash/{UUID}/"); - let (base, ref_) = parse_pathbase_ref(&url, None).unwrap(); - assert_eq!(base.as_deref(), Some("https://pathbase.dev")); - assert_eq!(ref_.id, UUID); - } - - #[test] - fn parse_pathbase_ref_short_route_with_graphs_delimiter() { - let url = format!("https://pathbase.dev/alex/pathstash/graphs/{UUID}"); - let (_, ref_) = parse_pathbase_ref(&url, None).unwrap(); - assert_eq!( - ref_, - PathRef { - owner: "alex".into(), - repo: "pathstash".into(), - id: UUID.into(), - } - ); - } - - #[test] - fn parse_pathbase_ref_legacy_paths_delimiter_still_parses() { - // Pre-1.1 share URLs used `///paths/`. Keep - // parsing them for back-compat — `id` still has to be a UUID - // because that's the only addressing scheme the new server - // understands; legacy slug-style refs no longer resolve. - let url = format!("https://pathbase.dev/anon/pathstash/paths/{UUID}"); - let (_, ref_) = parse_pathbase_ref(&url, None).unwrap(); - assert_eq!( - ref_, - PathRef { - owner: "anon".into(), - repo: "pathstash".into(), - id: UUID.into(), - } - ); - } - - #[test] - fn parse_pathbase_ref_rejects_non_uuid_trailing_segment() { - // Pathbase 1.1+ addresses graphs by UUID; a slug-style ref - // can no longer be resolved, so fail at the parse step. - assert!(parse_pathbase_ref("alex/pathstash/my-path", None).is_err()); - assert!(parse_pathbase_ref("https://pathbase.dev/alex/pathstash/my-path", None).is_err()); - } - - #[test] - fn parse_pathbase_ref_rejects_too_few_segments() { - assert!(parse_pathbase_ref("https://pathbase.dev/just-one", None).is_err()); - assert!(parse_pathbase_ref("just/two", None).is_err()); - } - fn setup_claude_manager() -> (tempfile::TempDir, toolpath_claude::ClaudeConvo) { let temp = tempfile::tempdir().unwrap(); let claude_dir = temp.path().join(".claude"); @@ -2027,18 +1575,4 @@ mod tests { assert!(d.cache_id.starts_with("claude-")); } } - - #[test] - #[cfg(not(target_os = "emscripten"))] - fn pathbase_fetch_to_doc_url_input() { - use crate::cmd_pathbase::tests::MockServer; - let body = r#"{"graph":{"id":"g1"},"paths":[{"path":{"id":"p1","head":"s1"},"steps":[{"step":{"id":"s1","actor":"agent:claude-code","timestamp":"2026-01-01T00:00:00Z"},"change":{}}]}]}"#; - let server = MockServer::start("HTTP/1.1 200 OK", body); - let url = format!("{}/u/alex/repos/pathstash/graphs/{UUID}", server.base()); - - let derived = pathbase_fetch_to_doc(&url, None).unwrap(); - - assert_eq!(derived.cache_id, format!("pathbase-alex-pathstash-{UUID}")); - assert!(derived.doc.into_single_path().is_some()); - } } diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index ae9e21d4..163634fe 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -44,10 +44,7 @@ use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; -/// Re-exported so external callers (integration tests, future consumers) -/// can construct [`ResumeArgs`] without depending on the `cmd_share` -/// module directly. -pub use crate::cmd_share::HarnessArg; +use crate::harness::Harness; #[derive(Args, Debug)] pub struct ResumeArgs { @@ -65,7 +62,7 @@ pub struct ResumeArgs { /// Pin the resume target. Skips the interactive picker. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the cache entirely when fetching from Pathbase: don't read /// an existing entry, don't write the fetched body. Useful for @@ -123,8 +120,7 @@ use toolpath::v1::{Graph, Path as TPath, PathOrRef}; /// Read a path's source harness from `meta.source` (set by /// `toolpath-convo::derive_path` to the provider id), falling back to /// actor-string sniffing across the path's steps. -pub(crate) fn infer_source_harness(path: &TPath) -> Option { - use crate::cmd_share::Harness; +pub(crate) fn infer_source_harness(path: &TPath) -> Option { let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref()); if let Some(source) = meta_source { match source { @@ -149,6 +145,9 @@ pub(crate) fn infer_source_harness(path: &TPath) -> Option Result<&TPath> { /// Resolve the user-supplied `` argument into a parsed `Graph` /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. -pub(crate) fn resolve_input( - args: &ResumeArgs, -) -> Result<(Graph, Option)> { +pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { let raw = args.input.as_str(); enum Shape<'a> { @@ -222,14 +219,15 @@ pub(crate) fn resolve_input( let graph: Graph = match shape { Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { // Probe the local cache before going to the network. The cache - // id is purely a function of (owner, repo, slug), so we can - // compute it without fetching. `--force` skips the probe and - // re-fetches; `--no-cache` skips both the probe AND the post- - // fetch write (still useful for ephemeral environments). - let cache_id = crate::cmd_import::pathbase_cache_id_of(u, args.url.as_deref())?; + // id is purely a function of the parsed (owner, repo, id), so + // we can compute it without fetching. `--force` skips the probe + // and re-fetches; `--no-cache` skips both the probe AND the + // post-fetch write (still useful for ephemeral environments). + let (_, ref_) = crate::derive::parse_pathbase_ref(u, args.url.as_deref())?; + let cache_id = crate::cache::pathbase_cache_id(&ref_.owner, &ref_.repo, &ref_.id); if !args.force && !args.no_cache - && let Ok(cache_path) = crate::cmd_cache::cache_path(&cache_id) + && let Ok(cache_path) = crate::cache::cache_path(&cache_id) && cache_path.exists() { let json = std::fs::read_to_string(&cache_path) @@ -238,12 +236,12 @@ pub(crate) fn resolve_input( Graph::from_json(&json) .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))? } else { - let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?; + let derived = crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref())?; if !args.no_cache { // force=true here: we either short-circuited above // (cache miss) or the user explicitly passed --force, // and either way we want the new bytes to land. - crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?; + crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; eprintln!("Resolved {} → {}", raw, derived.cache_id); } derived.doc @@ -255,7 +253,7 @@ pub(crate) fn resolve_input( .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? } Shape::CacheId(id) => { - let file = crate::cmd_cache::cache_ref(id).map_err(|e| { + let file = crate::cache::cache_ref(id).map_err(|e| { anyhow::anyhow!( "couldn't resolve `{}` as a URL, file path, or cache id: {}", raw, @@ -302,11 +300,7 @@ pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path> /// explicitly from the IDE's command palette, but `open -a Cursor` /// (macOS) / `xdg-open` (Linux) always work. Treat cursor as available /// when either path is open. -pub(crate) fn harness_available( - harness: crate::cmd_share::Harness, - path_override: Option<&std::path::Path>, -) -> bool { - use crate::cmd_share::Harness; +pub(crate) fn harness_available(harness: Harness, path_override: Option<&std::path::Path>) -> bool { if binary_on_path(harness.name(), path_override) { return true; } @@ -323,16 +317,6 @@ pub(crate) fn harness_available( false } -const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ - crate::cmd_share::Harness::Claude, - crate::cmd_share::Harness::Gemini, - crate::cmd_share::Harness::Codex, - crate::cmd_share::Harness::Copilot, - crate::cmd_share::Harness::Opencode, - crate::cmd_share::Harness::Cursor, - crate::cmd_share::Harness::Pi, -]; - /// Decide which harness to resume in. /// /// - If `arg` is `Some`, validate the named harness is on PATH and return it. @@ -341,14 +325,11 @@ const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ /// /// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`. pub(crate) fn pick_harness( - arg: Option, - source: Option, + arg: Option, + source: Option, path_override: Option<&std::path::Path>, -) -> Result { - use crate::cmd_share::Harness; - - if let Some(a) = arg { - let h = Harness::from_arg(a); +) -> Result { + if let Some(h) = arg { if !harness_available(h, path_override) { anyhow::bail!( "harness `{}` isn't on PATH; install it or pick another with `--harness`", @@ -358,7 +339,7 @@ pub(crate) fn pick_harness( return Ok(h); } - let installed: Vec = ALL_HARNESSES + let installed: Vec = Harness::ALL .iter() .copied() .filter(|h| harness_available(*h, path_override)) @@ -373,10 +354,7 @@ pub(crate) fn pick_harness( interactive_pick(&installed, source) } -fn interactive_pick( - installed: &[crate::cmd_share::Harness], - source: Option, -) -> Result { +fn interactive_pick(installed: &[Harness], source: Option) -> Result { if !crate::fuzzy::available() { let hint = if crate::fuzzy::embedded_picker_available() { "rerun in a terminal" @@ -388,7 +366,7 @@ fn interactive_pick( let mut lines: Vec = Vec::with_capacity(installed.len()); for h in installed { let suffix = if Some(*h) == source { " (source)" } else { "" }; - lines.push(format!("{}{}", h.symbol(), suffix)); + lines.push(format!("{}{}", h.padded_name(), suffix)); } let header = match source { @@ -411,8 +389,9 @@ fn interactive_pick( } }; + let picked_name = selected.split_whitespace().next().unwrap_or_default(); for h in installed { - if selected.starts_with(h.symbol()) { + if picked_name == h.name() { return Ok(*h); } } @@ -421,8 +400,7 @@ fn interactive_pick( /// Static map from harness to resume-argv shape. Lives here because /// it's a per-harness CLI convention, not a projection concern. -pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> Vec { - use crate::cmd_share::Harness; +pub(crate) fn argv_for(harness: Harness, session_id: &str) -> Vec { match harness { Harness::Claude => vec!["-r".into(), session_id.into()], Harness::Gemini => vec!["--resume".into(), session_id.into()], @@ -441,11 +419,10 @@ pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> } pub(crate) fn invocation_for( - harness: crate::cmd_share::Harness, + harness: Harness, session_id: &str, cwd: &std::path::Path, ) -> (String, Vec) { - use crate::cmd_share::Harness; if harness == Harness::Cursor { return cursor_invocation(cwd); } @@ -479,10 +456,9 @@ fn cursor_invocation(cwd: &std::path::Path) -> (String, Vec) { /// returning the projected session id. pub(crate) fn project_into_harness( path: &TPath, - harness: crate::cmd_share::Harness, + harness: Harness, cwd: &std::path::Path, ) -> Result { - use crate::cmd_share::Harness; match harness { Harness::Claude => crate::cmd_export::project_claude(path, cwd), Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), @@ -627,7 +603,7 @@ mod tests { let args = ResumeArgs { input: doc_file.to_string_lossy().to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), + harness: Some(Harness::Claude), no_cache: false, force: false, url: None, @@ -642,7 +618,6 @@ mod tests { assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); } - use crate::cmd_share::Harness; use toolpath::v1::{Graph, PathMeta, PathOrRef}; fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { @@ -923,10 +898,10 @@ mod tests { #[test] fn pick_harness_explicit_arg_validates_path() { let td = fake_path_with(&["claude"]); - let result = pick_harness(Some(HarnessArg::Claude), None, Some(td.path())); + let result = pick_harness(Some(Harness::Claude), None, Some(td.path())); assert_eq!(result.unwrap(), Harness::Claude); - let err = pick_harness(Some(HarnessArg::Gemini), None, Some(td.path())).unwrap_err(); + let err = pick_harness(Some(Harness::Gemini), None, Some(td.path())).unwrap_err(); assert!(err.to_string().contains("`gemini` isn't on PATH")); } @@ -935,7 +910,7 @@ mod tests { fn cursor_available_via_open_fallback_on_macos() { let td = fake_path_with(&["open"]); assert!(harness_available(Harness::Cursor, Some(td.path()))); - let picked = pick_harness(Some(HarnessArg::Cursor), None, Some(td.path())); + let picked = pick_harness(Some(Harness::Cursor), None, Some(td.path())); assert_eq!(picked.unwrap(), Harness::Cursor); } diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 1eb4f96f..b3965e3c 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -5,22 +5,15 @@ use anyhow::Result; use chrono::{DateTime, Utc}; -use clap::{Args, ValueEnum}; +use clap::Args; use std::path::PathBuf; +use crate::artifact::ArtifactType; use crate::cmd_export::RepoSpec; - -#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "lower")] -pub enum HarnessArg { - Claude, - Gemini, - Codex, - Copilot, - Opencode, - Cursor, - Pi, -} +use crate::harness::{ + Harness, HarnessBundle, is_not_found_claude, is_not_found_codex, is_not_found_copilot, + is_not_found_cursor, is_not_found_gemini, is_not_found_opencode, is_not_found_pi, +}; #[derive(Args, Debug)] pub struct ShareArgs { @@ -49,7 +42,7 @@ pub struct ShareArgs { /// Narrow the picker to one harness, or skip the picker entirely /// when used with --session. #[arg(long, value_enum)] - pub harness: Option, + pub harness: Option, /// Skip the picker. Requires --harness; requires --project for /// claude/gemini/pi. @@ -66,125 +59,24 @@ pub struct ShareArgs { pub no_cache: bool, } -/// Which agent harness a session was produced by. -#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] -pub(crate) enum Harness { - Claude, - Gemini, - Codex, - Copilot, - Opencode, - Cursor, - Pi, -} - -impl Harness { - pub(crate) fn name(&self) -> &'static str { - match self { - Harness::Claude => "claude", - Harness::Gemini => "gemini", - Harness::Codex => "codex", - Harness::Copilot => "copilot", - Harness::Opencode => "opencode", - Harness::Cursor => "cursor", - Harness::Pi => "pi", - } - } - - /// Padded so all symbols line up in the fzf column. Longest is - /// "opencode" (8); pad shorter names to match. - pub(crate) fn symbol(&self) -> &'static str { - match self { - Harness::Claude => "claude ", - Harness::Gemini => "gemini ", - Harness::Codex => "codex ", - Harness::Copilot => "copilot ", - Harness::Opencode => "opencode", - Harness::Cursor => "cursor ", - Harness::Pi => "pi ", - } - } - - /// True when the underlying provider keys sessions by project path. - /// claude/gemini/pi: true. codex/opencode/cursor: false (sessions - /// store cwd per-row, not as a directory key — cursor stores it as - /// `workspaceIdentifier.uri.fsPath` on each composer). - pub(crate) fn project_keyed(&self) -> bool { - matches!(self, Harness::Claude | Harness::Gemini | Harness::Pi) - } - - pub(crate) fn from_arg(arg: HarnessArg) -> Self { - match arg { - HarnessArg::Claude => Harness::Claude, - HarnessArg::Gemini => Harness::Gemini, - HarnessArg::Codex => Harness::Codex, - HarnessArg::Copilot => Harness::Copilot, - HarnessArg::Opencode => Harness::Opencode, - HarnessArg::Cursor => Harness::Cursor, - HarnessArg::Pi => Harness::Pi, - } - } - - pub(crate) fn parse(s: &str) -> Option { - match s { - "claude" => Some(Harness::Claude), - "gemini" => Some(Harness::Gemini), - "codex" => Some(Harness::Codex), - "copilot" => Some(Harness::Copilot), - "opencode" => Some(Harness::Opencode), - "cursor" => Some(Harness::Cursor), - "pi" => Some(Harness::Pi), - _ => None, - } - } -} - -/// One row in the unified session picker. +/// One artifact surfaced by a provider — today always an agent session. +/// Rows feed both the unified `share` picker and `p cache sync`. #[derive(Debug, Clone)] -pub(crate) struct SessionRow { - pub(crate) harness: Harness, +pub(crate) struct ArtifactRow { + pub(crate) artifact_type: ArtifactType, /// Project path for keyed providers; `None` for codex/opencode. - pub(crate) project: Option, + pub(crate) path: Option, /// Recorded cwd from the session (codex/opencode only). pub(crate) cwd: Option, pub(crate) session_id: String, pub(crate) title: String, pub(crate) last_activity: Option>, - pub(crate) message_count: usize, + /// Message count — populated only for harness artifact types + /// (agent sessions); `None` for future non-session artifact kinds. + pub(crate) message_count: Option, pub(crate) matches_cwd: bool, } -/// Bundle of provider managers used during aggregation. Production code -/// builds this from real `$HOME` via `from_environment`; tests construct -/// it directly with provider-specific resolvers. -#[derive(Default)] -pub(crate) struct HarnessBundle { - pub(crate) claude: Option, - pub(crate) gemini: Option, - pub(crate) codex: Option, - pub(crate) copilot: Option, - pub(crate) opencode: Option, - pub(crate) cursor: Option, - pub(crate) pi: Option, -} - -impl HarnessBundle { - /// Build the production bundle. Each provider is included - /// unconditionally (its `new()` doesn't fail on a missing home dir); - /// `gather_sessions` skips the ones whose listing returns empty/NotFound. - pub(crate) fn from_environment() -> Self { - Self { - claude: Some(toolpath_claude::ClaudeConvo::new()), - gemini: Some(toolpath_gemini::GeminiConvo::new()), - codex: Some(toolpath_codex::CodexConvo::new()), - copilot: Some(toolpath_copilot::CopilotConvo::new()), - opencode: Some(toolpath_opencode::OpencodeConvo::new()), - cursor: Some(toolpath_cursor::CursorConvo::new()), - pi: Some(toolpath_pi::PiConvo::new()), - } - } -} - /// Aggregate sessions across the harnesses in `bundle`, ranked so that /// rows whose project (or recorded cwd) canonicalizes to `cwd` come /// first, sorted by descending `last_activity`. @@ -192,49 +84,49 @@ impl HarnessBundle { /// Filters: `harness_filter` keeps only rows from one harness; `project_filter` /// keeps only rows whose project (for keyed) or cwd (for session-keyed) /// canonicalizes to that path. -pub(crate) fn gather_sessions( +pub(crate) fn gather_artifacts( bundle: &HarnessBundle, cwd: &std::path::Path, - harness_filter: Option, + harness_filter: Option, project_filter: Option<&std::path::Path>, -) -> Vec { +) -> Vec { let mut rows = Vec::new(); let canonical_cwd = canonicalize_or_self(cwd); let canonical_project = project_filter.map(canonicalize_or_self); - let want = |h: Harness| harness_filter.is_none_or(|f| f == h); + let want = |h: ArtifactType| harness_filter.is_none_or(|f| f == h); - if want(Harness::Claude) + if want(ArtifactType::Claude) && let Some(mgr) = &bundle.claude { collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Gemini) + if want(ArtifactType::Gemini) && let Some(mgr) = &bundle.gemini { collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Pi) + if want(ArtifactType::Pi) && let Some(mgr) = &bundle.pi { collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Codex) + if want(ArtifactType::Codex) && let Some(mgr) = &bundle.codex { collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Copilot) + if want(ArtifactType::Copilot) && let Some(mgr) = &bundle.copilot { collect_copilot(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Opencode) + if want(ArtifactType::Opencode) && let Some(mgr) = &bundle.opencode { collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); } - if want(Harness::Cursor) + if want(ArtifactType::Cursor) && let Some(mgr) = &bundle.cursor { collect_cursor(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); @@ -260,7 +152,7 @@ fn collect_claude( mgr: &toolpath_claude::ClaudeConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -287,16 +179,16 @@ fn collect_claude( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - out.push(SessionRow { - harness: Harness::Claude, - project: Some(m.project_path), + out.push(ArtifactRow { + artifact_type: ArtifactType::Claude, + path: Some(m.project_path), cwd: None, session_id: m.session_id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } @@ -307,7 +199,7 @@ fn collect_gemini( mgr: &toolpath_gemini::GeminiConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -334,16 +226,16 @@ fn collect_gemini( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - out.push(SessionRow { - harness: Harness::Gemini, - project: Some(m.project_path), + out.push(ArtifactRow { + artifact_type: ArtifactType::Gemini, + path: Some(m.project_path), cwd: None, session_id: m.session_uuid, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } @@ -354,7 +246,7 @@ fn collect_pi( mgr: &toolpath_pi::PiConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let projects = match mgr.list_projects() { Ok(ps) if !ps.is_empty() => ps, @@ -381,20 +273,29 @@ fn collect_pi( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { - // SessionMeta.timestamp is a String; parse to DateTime when possible. - let last_activity = chrono::DateTime::parse_from_rfc3339(&m.timestamp) + // Pi's SessionMeta.timestamp is the session *start*, so it + // never moves as the session grows; prefer the file's mtime + // as the change-detecting last_activity, falling back to + // the header timestamp when the stat fails. + let last_activity = std::fs::metadata(&m.file_path) + .and_then(|md| md.modified()) .ok() - .map(|d| d.with_timezone(&Utc)); - out.push(SessionRow { - harness: Harness::Pi, - project: Some(project.clone()), + .map(DateTime::::from) + .or_else(|| { + chrono::DateTime::parse_from_rfc3339(&m.timestamp) + .ok() + .map(|d| d.with_timezone(&Utc)) + }); + out.push(ArtifactRow { + artifact_type: ArtifactType::Pi, + path: Some(project.clone()), cwd: None, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity, - message_count: m.entry_count, + message_count: Some(m.entry_count), matches_cwd, }); } @@ -405,7 +306,7 @@ fn collect_codex( mgr: &toolpath_codex::CodexConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.list_sessions() { Ok(m) if !m.is_empty() => m, @@ -432,16 +333,16 @@ fn collect_codex( .as_deref() .map(|p| paths_match(p, canonical_cwd)) .unwrap_or(false); - out.push(SessionRow { - harness: Harness::Codex, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Codex, + path: None, cwd: cwd_str, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.line_count, + message_count: Some(m.line_count), matches_cwd, }); } @@ -451,7 +352,7 @@ fn collect_copilot( mgr: &toolpath_copilot::CopilotConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.list_sessions() { Ok(m) if !m.is_empty() => m, @@ -475,16 +376,16 @@ fn collect_copilot( .as_deref() .map(|p| paths_match(p, canonical_cwd)) .unwrap_or(false); - out.push(SessionRow { - harness: Harness::Copilot, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Copilot, + path: None, cwd: m.cwd, session_id: m.id, title: m .first_user_message .unwrap_or_else(|| "(no prompt)".to_string()), last_activity: m.last_activity, - message_count: m.line_count, + message_count: Some(m.line_count), matches_cwd, }); } @@ -494,7 +395,7 @@ fn collect_opencode( mgr: &toolpath_opencode::OpencodeConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata(None) { Ok(m) if !m.is_empty() => m, @@ -518,14 +419,14 @@ fn collect_opencode( (_, false) => m.title.clone(), _ => "(no prompt)".to_string(), }; - out.push(SessionRow { - harness: Harness::Opencode, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Opencode, + path: None, cwd: Some(cwd_str), session_id: m.id, title, last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } @@ -535,7 +436,7 @@ fn collect_cursor( mgr: &toolpath_cursor::CursorConvo, canonical_cwd: &std::path::Path, project_filter: Option<&std::path::Path>, - out: &mut Vec, + out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata() { Ok(m) if !m.is_empty() => m, @@ -567,71 +468,21 @@ fn collect_cursor( (_, Some(n)) if !n.is_empty() => n.clone(), _ => "(no prompt)".to_string(), }; - out.push(SessionRow { - harness: Harness::Cursor, - project: None, + out.push(ArtifactRow { + artifact_type: ArtifactType::Cursor, + path: None, cwd: Some(cwd_str), session_id: m.id, title, last_activity: m.last_activity, - message_count: m.message_count, + message_count: Some(m.message_count), matches_cwd, }); } } -fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { - use toolpath_claude::ConvoError; - matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) - || matches!(err, ConvoError::ClaudeDirectoryNotFound(_)) -} - -fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool { - use toolpath_gemini::ConvoError; - matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) - || matches!(err, ConvoError::GeminiDirectoryNotFound(_)) -} - -fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool { - use toolpath_pi::PiError; - matches!(err, PiError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, PiError::ProjectNotFound(_)) -} - -fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { - use toolpath_codex::ConvoError; - matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) - || matches!(err, ConvoError::CodexDirectoryNotFound(_)) -} - -fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool { - use toolpath_copilot::ConvoError; - matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) - || matches!(err, ConvoError::CopilotDirectoryNotFound(_)) -} - -fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { - use toolpath_opencode::ConvoError; - matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) - || matches!(err, ConvoError::OpencodeDirectoryNotFound(_)) - || matches!(err, ConvoError::DatabaseNotFound(_)) -} - -fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { - use toolpath_cursor::CursorError; - matches!(err, CursorError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, CursorError::NoHomeDirectory) - || matches!(err, CursorError::CursorDataDirectoryNotFound(_)) - || matches!(err, CursorError::DatabaseNotFound(_)) -} - pub fn run(args: ShareArgs) -> Result<()> { - let harness = args.harness.map(Harness::from_arg); + let harness = args.harness.map(|h| h.artifact_type()); if args.session.is_some() && harness.is_none() { anyhow::bail!("--session requires --harness"); @@ -660,7 +511,7 @@ pub fn run(args: ShareArgs) -> Result<()> { let cwd = std::env::current_dir()?; let bundle = HarnessBundle::from_environment(); let project_filter = args.project.as_deref(); - let rows = gather_sessions(&bundle, &cwd, harness, project_filter); + let rows = gather_artifacts(&bundle, &cwd, harness, project_filter); if rows.is_empty() { return bail_no_sessions(&bundle, project_filter); @@ -720,9 +571,9 @@ pub fn run(args: ShareArgs) -> Result<()> { repo: args.repo.clone(), name: args.name.clone(), public: args.public, - harness: Some(harness_to_arg(h)), + harness: h.harness(), session: None, // unused by share_explicit - project: if h.project_keyed() { + project: if h.path_keyed() { Some(PathBuf::from(&key)) } else { None @@ -736,18 +587,6 @@ pub fn run(args: ShareArgs) -> Result<()> { share_explicit(h, &session, &explicit, auth, base_url) } -fn harness_to_arg(h: Harness) -> HarnessArg { - match h { - Harness::Claude => HarnessArg::Claude, - Harness::Gemini => HarnessArg::Gemini, - Harness::Codex => HarnessArg::Codex, - Harness::Copilot => HarnessArg::Copilot, - Harness::Opencode => HarnessArg::Opencode, - Harness::Cursor => HarnessArg::Cursor, - Harness::Pi => HarnessArg::Pi, - } -} - fn bail_no_sessions( bundle: &HarnessBundle, project_filter: Option<&std::path::Path>, @@ -947,13 +786,13 @@ fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> Stri } fn share_explicit( - harness: Harness, + harness: ArtifactType, session: &str, args: &ShareArgs, auth: crate::cmd_pathbase::AuthMode, base_url: String, ) -> Result<()> { - let project = match (harness.project_keyed(), args.project.as_ref()) { + let project = match (harness.path_keyed(), args.project.as_ref()) { (true, Some(p)) => Some(p.to_string_lossy().into_owned()), (true, None) => anyhow::bail!( "--project required when --harness is {} and --session is set", @@ -973,7 +812,7 @@ fn share_explicit( // the upload uses the fresh body, not the cache. Always // overwrite so cache and upload agree (use `--no-cache` to skip // the cache write entirely). - let path = crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?; + let path = crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; eprintln!( "Cached {} session → {} ({})", harness.name(), @@ -1002,25 +841,27 @@ fn share_explicit( /// The display column is space-padded rather than tab-separated so the /// columns line up consistently across pickers — terminal tab stops /// produce ugly variable gaps in both fzf and skim. -fn format_picker_row(row: &SessionRow) -> String { +fn format_picker_row(row: &ArtifactRow) -> String { let key = row - .project + .path .clone() .or_else(|| row.cwd.clone()) .unwrap_or_default(); let scope = if row.matches_cwd { "·" } else { " " }; - let leading = format!("{scope} {}", row.harness.symbol()); + let leading = format!("{scope} {}", row.artifact_type.padded_name()); let display = render_row( Some(&leading), row.last_activity, - &count(row.message_count, "msgs"), + &row.message_count + .map(|c| count(c, "msgs")) + .unwrap_or_default(), Some(&project_short(&key)), &row.title, ); let title = clean_for_picker_display(&row.title); format!( "{}\t{}\t{}\t{}\t{}", - row.harness.name(), + row.artifact_type.name(), tab_safe(&key), tab_safe(&row.session_id), display, @@ -1031,9 +872,9 @@ fn format_picker_row(row: &SessionRow) -> String { /// Inverse of [`format_picker_row`] — pulls (harness, key, session, /// title) back out of the line the picker returned. Returns `None` if /// the line is malformed. -fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> { +fn parse_picker_row(line: &str) -> Option<(ArtifactType, String, String, String)> { let mut parts = line.split('\t'); - let h = Harness::parse(parts.next()?)?; + let h = ArtifactType::parse(parts.next()?)?; let key = parts.next()?.to_string(); let session = parts.next()?.to_string(); if session.is_empty() { @@ -1048,26 +889,27 @@ fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> { use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe}; fn derive_session( - harness: Harness, + harness: ArtifactType, project: Option<&str>, session: &str, -) -> Result { +) -> Result { match harness { - Harness::Claude => { - crate::cmd_import::derive_claude_session(project.expect("project_keyed"), session) + ArtifactType::Claude => { + crate::derive::derive_claude_session(project.expect("path_keyed"), session) } - Harness::Gemini => crate::cmd_import::derive_gemini_session( - project.expect("project_keyed"), - session, - false, - ), - Harness::Pi => { - crate::cmd_import::derive_pi_session(project.expect("project_keyed"), session, None) + ArtifactType::Gemini => { + crate::derive::derive_gemini_session(project.expect("path_keyed"), session) + } + ArtifactType::Copilot => crate::derive::derive_copilot_session(session), + ArtifactType::Pi => { + crate::derive::derive_pi_session(project.expect("path_keyed"), session, None) + } + ArtifactType::Codex => crate::derive::derive_codex_session(session), + ArtifactType::Opencode => crate::derive::derive_opencode_session(session, false), + ArtifactType::Cursor => crate::derive::derive_cursor_session(session), + ArtifactType::Git => { + anyhow::bail!("share only handles agent sessions; git artifacts go through `p import`") } - Harness::Codex => crate::cmd_import::derive_codex_session(session), - Harness::Copilot => crate::cmd_import::derive_copilot_session(session), - Harness::Opencode => crate::cmd_import::derive_opencode_session(session, false), - Harness::Cursor => crate::cmd_import::derive_cursor_session(session), } } @@ -1075,58 +917,6 @@ fn derive_session( mod tests { use super::*; - #[test] - fn harness_name_and_symbol_are_distinct() { - let all = [ - Harness::Claude, - Harness::Gemini, - Harness::Codex, - Harness::Opencode, - Harness::Cursor, - Harness::Pi, - ]; - let names: Vec<&str> = all.iter().map(|h| h.name()).collect(); - let symbols: Vec<&str> = all.iter().map(|h| h.symbol()).collect(); - assert_eq!(names.len(), 6); - assert_eq!( - names.iter().collect::>().len(), - 6, - "names must be unique" - ); - assert_eq!( - symbols - .iter() - .collect::>() - .len(), - 6, - "symbols must be unique" - ); - } - - #[test] - fn harness_project_keyed_matches_design() { - assert!(Harness::Claude.project_keyed()); - assert!(Harness::Gemini.project_keyed()); - assert!(Harness::Pi.project_keyed()); - assert!(!Harness::Codex.project_keyed()); - assert!(!Harness::Opencode.project_keyed()); - assert!(!Harness::Cursor.project_keyed()); - } - - #[test] - fn harness_from_arg_roundtrips() { - for (arg, harness) in [ - (HarnessArg::Claude, Harness::Claude), - (HarnessArg::Gemini, Harness::Gemini), - (HarnessArg::Codex, Harness::Codex), - (HarnessArg::Opencode, Harness::Opencode), - (HarnessArg::Cursor, Harness::Cursor), - (HarnessArg::Pi, Harness::Pi), - ] { - assert_eq!(Harness::from_arg(arg), harness); - } - } - use std::path::Path; use tempfile::TempDir; @@ -1157,7 +947,7 @@ mod tests { } #[test] - fn gather_sessions_includes_claude_rows_for_a_project() { + fn gather_artifacts_includes_claude_rows_for_a_project() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1167,17 +957,17 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/test/project"); - let rows = gather_sessions(&bundle, cwd, None, None); + let rows = gather_artifacts(&bundle, cwd, None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Claude); + assert_eq!(rows[0].artifact_type, ArtifactType::Claude); assert_eq!(rows[0].session_id, "abc-session-one"); - assert_eq!(rows[0].project.as_deref(), Some("/test/project")); + assert_eq!(rows[0].path.as_deref(), Some("/test/project")); assert!(rows[0].matches_cwd, "cwd should match the project path"); } #[test] - fn gather_sessions_marks_non_matching_project_rows() { + fn gather_artifacts_marks_non_matching_project_rows() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1187,22 +977,22 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/some/other/place"); - let rows = gather_sessions(&bundle, cwd, None, None); + let rows = gather_artifacts(&bundle, cwd, None, None); assert_eq!(rows.len(), 1); assert!(!rows[0].matches_cwd); } #[test] - fn gather_sessions_skips_harness_with_no_home_dir() { + fn gather_artifacts_skips_harness_with_no_home_dir() { // Empty bundle => no rows, no panic. let bundle = HarnessBundle::default(); - let rows = gather_sessions(&bundle, Path::new("/anywhere"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/anywhere"), None, None); assert!(rows.is_empty()); } #[test] - fn gather_sessions_filters_by_harness() { + fn gather_artifacts_filters_by_harness() { let temp = TempDir::new().unwrap(); write_claude_session( &temp.path().join(".claude"), @@ -1212,7 +1002,7 @@ mod tests { ); let bundle = claude_only_bundle(temp.path()); let cwd = Path::new("/test/project"); - let rows = gather_sessions(&bundle, cwd, Some(Harness::Codex), None); + let rows = gather_artifacts(&bundle, cwd, Some(ArtifactType::Codex), None); assert!(rows.is_empty(), "filter to codex must drop claude rows"); } @@ -1239,7 +1029,7 @@ mod tests { } #[test] - fn gather_sessions_includes_codex_rows_with_cwd_match() { + fn gather_artifacts_includes_codex_rows_with_cwd_match() { let temp = TempDir::new().unwrap(); write_codex_session( &temp.path().join(".codex"), @@ -1247,9 +1037,9 @@ mod tests { "/work/proj", ); let bundle = codex_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/work/proj"), None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Codex); + assert_eq!(rows[0].artifact_type, ArtifactType::Codex); assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj")); assert!(rows[0].matches_cwd); } @@ -1281,9 +1071,9 @@ mod tests { let temp = TempDir::new().unwrap(); write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/work/proj"), None, None); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].harness, Harness::Copilot); + assert_eq!(rows[0].artifact_type, ArtifactType::Copilot); assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj")); assert!(rows[0].matches_cwd); } @@ -1294,12 +1084,17 @@ mod tests { write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj"); let bundle = copilot_only_bundle(temp.path()); // Filtering to a different harness drops the copilot row. - let rows = gather_sessions(&bundle, Path::new("/work/proj"), Some(Harness::Codex), None); + let rows = gather_artifacts( + &bundle, + Path::new("/work/proj"), + Some(ArtifactType::Codex), + None, + ); assert!(rows.is_empty()); } #[test] - fn gather_sessions_ranks_cwd_matches_first() { + fn gather_artifacts_ranks_cwd_matches_first() { // Two claude sessions: one in cwd (older), one elsewhere (newer). // Despite the elsewhere row being newer, the cwd-match must come first. let temp = TempDir::new().unwrap(); @@ -1315,7 +1110,7 @@ mod tests { ) .unwrap(); let bundle = claude_only_bundle(temp.path()); - let rows = gather_sessions(&bundle, Path::new("/cwd/project"), None, None); + let rows = gather_artifacts(&bundle, Path::new("/cwd/project"), None, None); assert_eq!(rows.len(), 2); assert_eq!(rows[0].session_id, "in-cwd-session"); @@ -1326,14 +1121,14 @@ mod tests { #[test] #[cfg(unix)] fn paths_match_canonicalizes_through_symlink() { - // `paths_match` is the function that produces `SessionRow.matches_cwd` + // `paths_match` is the function that produces `ArtifactRow.matches_cwd` // (collect_* all delegate to it). Without canonicalization, a user who // navigated to a project via a symlink would see their cwd-row sink // in the picker because the symlink path string ≠ the project path // string. Verify both arguments are canonicalized. // // Note: we test `paths_match` directly rather than going through - // `gather_sessions` because Claude's project-dir slug encoding is + // `gather_artifacts` because Claude's project-dir slug encoding is // lossy (sanitize_project_path: '/', '_', '.' → '-'; unsanitize: only // '-' → '/'). On macOS, tempdir paths contain '.' and end up under // /private/var/..., so the unsanitized slug never round-trips back to @@ -1367,19 +1162,19 @@ mod tests { #[test] fn parse_picker_row_roundtrips_keyed() { - let row = SessionRow { - harness: Harness::Claude, - project: Some("/tmp/proj".to_string()), + let row = ArtifactRow { + artifact_type: ArtifactType::Claude, + path: Some("/tmp/proj".to_string()), cwd: None, session_id: "sess-abc".to_string(), title: "Hello\tworld".to_string(), last_activity: None, - message_count: 3, + message_count: None, matches_cwd: true, }; let line = format_picker_row(&row); let (harness, key, session, title) = parse_picker_row(&line).unwrap(); - assert_eq!(harness, Harness::Claude); + assert_eq!(harness, ArtifactType::Claude); assert_eq!(key, "/tmp/proj"); assert_eq!(session, "sess-abc"); // tab_safe replaces the tab with a space, but the title content @@ -1389,19 +1184,19 @@ mod tests { #[test] fn parse_picker_row_roundtrips_session_keyed() { - let row = SessionRow { - harness: Harness::Codex, - project: None, + let row = ArtifactRow { + artifact_type: ArtifactType::Codex, + path: None, cwd: Some("/work/proj".to_string()), session_id: "0190abcd".to_string(), title: "(no prompt)".to_string(), last_activity: None, - message_count: 0, + message_count: None, matches_cwd: false, }; let line = format_picker_row(&row); let (harness, key, session, title) = parse_picker_row(&line).unwrap(); - assert_eq!(harness, Harness::Codex); + assert_eq!(harness, ArtifactType::Codex); assert_eq!(key, "/work/proj"); // codex has no project; cwd carried as the keyed slot assert_eq!(session, "0190abcd"); assert_eq!(title, "(no prompt)"); @@ -1409,14 +1204,14 @@ mod tests { #[test] fn parse_picker_row_carries_title_with_unicode() { - let row = SessionRow { - harness: Harness::Gemini, - project: Some("/work/proj".to_string()), + let row = ArtifactRow { + artifact_type: ArtifactType::Gemini, + path: Some("/work/proj".to_string()), cwd: None, session_id: "11111111-2222-3333-4444-555555555555".to_string(), title: "Add the share command — finally".to_string(), last_activity: None, - message_count: 42, + message_count: None, matches_cwd: true, }; let line = format_picker_row(&row); diff --git a/crates/path-cli/src/derive.rs b/crates/path-cli/src/derive.rs new file mode 100644 index 00000000..4ce230e3 --- /dev/null +++ b/crates/path-cli/src/derive.rs @@ -0,0 +1,468 @@ +//! Single-session derivation shared by `p import`, `share`, and the +//! other surfaces that turn one external artifact into a cached +//! toolpath document: `DerivedDoc` plus the per-provider +//! `derive_*_session` helpers and the Pathbase fetch used by +//! `p import pathbase` and `resume`. + +use anyhow::Result; +use std::path::PathBuf; +use toolpath::v1::Graph; + +use crate::artifact::ArtifactType; +use crate::cache::make_id; + +pub(crate) struct DerivedDoc { + pub(crate) cache_id: String, + pub(crate) doc: Graph, +} + +/// Derive a single Claude conversation given an explicit project + session. +/// Used by `cmd_share` after its picker has resolved the pair; mirrors the +/// `(Some(p), Some(s), _)` arm in [`derive_claude_with_manager`]. +pub(crate) fn derive_claude_session(project: &str, session: &str) -> Result { + derive_claude_session_with(&toolpath_claude::ClaudeConvo::new(), project, session) +} + +/// [`derive_claude_session`] against a caller-supplied manager, so sync +/// derives from the same roots it enumerated (and tests can inject a +/// fixture resolver). +pub(crate) fn derive_claude_session_with( + manager: &toolpath_claude::ClaudeConvo, + project: &str, + session: &str, +) -> Result { + // The caller's project string often comes from claude's lossy dir + // slugs ('/', '_', '.' all collapsed); leaving it out of the derive + // lets path.base come from the session's own recorded cwd instead. + // The cache is the archive: always derive maximally (thinking + // included). + let cfg = toolpath_claude::derive::DeriveConfig { + project_path: None, + include_thinking: true, + }; + let convo = manager + .read_conversation(project, session) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let mut path = toolpath_claude::derive::derive_path(&convo, &cfg); + // Sessions whose entries carry no cwd would otherwise derive with no + // base at all — fall back to the caller's project for those. + if path.path.base.is_none() && project.starts_with('/') { + path.path.base = Some(toolpath::v1::Base { + uri: format!("file://{project}"), + ref_str: None, + branch: None, + }); + } + let cache_id = make_id(ArtifactType::Claude.name(), &path.path.id); + Ok(DerivedDoc { + cache_id, + doc: Graph::from_path(path), + }) +} + +/// Derive a single Gemini conversation given an explicit project + session. +pub(crate) fn derive_gemini_session(project: &str, session: &str) -> Result { + derive_gemini_session_with(&toolpath_gemini::GeminiConvo::new(), project, session) +} + +/// [`derive_gemini_session`] against a caller-supplied manager. +pub(crate) fn derive_gemini_session_with( + manager: &toolpath_gemini::GeminiConvo, + project: &str, + session: &str, +) -> Result { + // Maximal ingest: thinking is always derived into the cache. + let cfg = toolpath_gemini::derive::DeriveConfig { + project_path: Some(project.to_string()), + include_thinking: true, + }; + let convo = manager + .read_conversation(project, session) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let path = toolpath_gemini::derive::derive_path(&convo, &cfg); + let cache_id = make_id(ArtifactType::Gemini.name(), &path.path.id); + Ok(DerivedDoc { + cache_id, + doc: Graph::from_path(path), + }) +} + +/// Derive a single Codex session given an explicit session id. +pub(crate) fn derive_codex_session(session: &str) -> Result { + derive_codex_session_with(&toolpath_codex::CodexConvo::new(), session) +} + +/// [`derive_codex_session`] against a caller-supplied manager. +pub(crate) fn derive_codex_session_with( + manager: &toolpath_codex::CodexConvo, + session: &str, +) -> Result { + let config = toolpath_codex::derive::DeriveConfig { project_path: None }; + let s = manager + .read_session(session) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let path = toolpath_codex::derive::derive_path(&s, &config); + let cache_id = make_id(ArtifactType::Codex.name(), &path.path.id); + Ok(DerivedDoc { + cache_id, + doc: Graph::from_path(path), + }) +} + +/// Derive a single Copilot session given an explicit session id. +pub(crate) fn derive_copilot_session(session: &str) -> Result { + derive_copilot_session_with(&toolpath_copilot::CopilotConvo::new(), session) +} + +/// [`derive_copilot_session`] against a caller-supplied manager. +pub(crate) fn derive_copilot_session_with( + manager: &toolpath_copilot::CopilotConvo, + session: &str, +) -> Result { + let config = toolpath_copilot::derive::DeriveConfig { project_path: None }; + let s = manager + .read_session(session) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let path = toolpath_copilot::derive::derive_path(&s, &config); + let cache_id = make_id(ArtifactType::Copilot.name(), &path.path.id); + Ok(DerivedDoc { + cache_id, + doc: Graph::from_path(path), + }) +} + +/// Derive a single opencode session given an explicit session id. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_opencode_session( + session: &str, + no_snapshot_diffs: bool, +) -> Result { + derive_opencode_session_with( + &toolpath_opencode::OpencodeConvo::new(), + session, + no_snapshot_diffs, + ) +} + +/// [`derive_opencode_session`] against a caller-supplied manager. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_opencode_session_with( + manager: &toolpath_opencode::OpencodeConvo, + session: &str, + no_snapshot_diffs: bool, +) -> Result { + let config = toolpath_opencode::derive::DeriveConfig { + no_snapshot_diffs, + ..Default::default() + }; + let s = manager + .read_session(session) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let path = + toolpath_opencode::derive::derive_path_with_resolver(&s, &config, manager.resolver()); + let cache_id = make_id(ArtifactType::Opencode.name(), &path.path.id); + Ok(DerivedDoc { + cache_id, + doc: Graph::from_path(path), + }) +} + +/// Derive a single cursor composer given an explicit composer id. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_cursor_session(session: &str) -> Result { + derive_cursor_session_with(&toolpath_cursor::CursorConvo::new(), session) +} + +/// [`derive_cursor_session`] against a caller-supplied manager. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn derive_cursor_session_with( + manager: &toolpath_cursor::CursorConvo, + session: &str, +) -> Result { + let s = manager + .read_session(session) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let cfg = toolpath_cursor::DeriveConfig::default(); + let path = toolpath_cursor::derive_path(&s, &cfg); + let cache_id = make_id(ArtifactType::Cursor.name(), &path.path.id); + Ok(DerivedDoc { + cache_id, + doc: Graph::from_path(path), + }) +} + +/// Derive a single Pi session given an explicit project + session. +pub(crate) fn derive_pi_session( + project: &str, + session: &str, + base: Option, +) -> Result { + let manager = if let Some(path) = base { + let resolver = toolpath_pi::PathResolver::new().with_sessions_dir(&path); + toolpath_pi::PiConvo::with_resolver(resolver) + } else { + toolpath_pi::PiConvo::new() + }; + derive_pi_session_with(&manager, project, session) +} + +/// [`derive_pi_session`] against a caller-supplied manager. +pub(crate) fn derive_pi_session_with( + manager: &toolpath_pi::PiConvo, + project: &str, + session: &str, +) -> Result { + let config = toolpath_pi::DeriveConfig::default(); + let session = manager + .read_session(project, session) + .map_err(|e| anyhow::anyhow!("{}", e))?; + let doc = Graph::from_path(toolpath_pi::derive::derive_path(&session, &config)); + let cache_id = make_id(ArtifactType::Pi.name(), &doc_inner_id(&doc)); + Ok(DerivedDoc { cache_id, doc }) +} + +/// Fetch a Pathbase ref (`https://host/u/owner/repos/repo/graphs/` +/// URL or bare `owner/repo/` triple) and parse it as a toolpath +/// document. Used by `path import pathbase` and `path resume `. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result { + use crate::cmd_pathbase::{credentials_path, graphs_download, load_session, resolve_url}; + + let (base, ref_) = parse_pathbase_ref(target, url_flag)?; + let stored = load_session(&credentials_path()?)?; + let base_url = base + .or_else(|| stored.as_ref().map(|s| s.url.clone())) + .unwrap_or_else(|| resolve_url(None)); + + let token = stored.as_ref().map(|s| s.token.as_str()); + + let PathRef { owner, repo, id } = ref_; + let body = graphs_download(&base_url, token, &owner, &repo, &id)?; + let cache_id = crate::cache::pathbase_cache_id(&owner, &repo, &id); + let doc = Graph::from_json(&body) + .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; + Ok(DerivedDoc { cache_id, doc }) +} + +/// What the user pointed at on the import side. Pathbase 1.1+ +/// addresses graphs by UUID, so `id` is always a parseable UUID string; +/// `parse_pathbase_ref` rejects non-UUID trailing segments. +#[cfg(not(target_os = "emscripten"))] +#[derive(Debug, PartialEq)] +pub(crate) struct PathRef { + pub(crate) owner: String, + pub(crate) repo: String, + pub(crate) id: String, +} + +/// Parse a positional ref for `path import pathbase`. Returns `(override_base, ref)`. +/// +/// Accepted shapes (trailing identifier must be a UUID): +/// - Full URL: `https://host/u//repos//graphs/` — +/// host overrides the server URL. Also accepts the older +/// `https://host///{paths|graphs}/` shape and the +/// short `https://host///` form. +/// - `//` — bare triple, used with `--url` or the +/// stored session. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn parse_pathbase_ref( + target: &str, + url_flag: Option<&str>, +) -> Result<(Option, PathRef)> { + use crate::cmd_pathbase::resolve_url; + + let scheme = if target.starts_with("https://") { + Some("https://") + } else if target.starts_with("http://") { + Some("http://") + } else { + None + }; + + if let Some(scheme) = scheme { + let rest = &target[scheme.len()..]; + let (host, path) = match rest.split_once('/') { + Some((h, p)) => (h, p), + None => anyhow::bail!("URL has no path segments: {target}"), + }; + if host.is_empty() { + anyhow::bail!("URL is missing a host: {target}"); + } + let path = path.split(['?', '#']).next().unwrap_or(""); + let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let triple = extract_triple(&segs).ok_or_else(|| { + anyhow::anyhow!("expected URL ending in ///graphs/ (got {target})") + })?; + Ok((Some(format!("{scheme}{host}")), triple)) + } else { + let base = url_flag.map(|u| resolve_url(Some(u.to_string()))); + let segs: Vec<&str> = target.split('/').filter(|s| !s.is_empty()).collect(); + let triple = extract_triple(&segs) + .ok_or_else(|| anyhow::anyhow!("expected `//`, got `{target}`"))?; + Ok((base, triple)) + } +} + +/// Pull (owner, repo, uuid) from a slash-split URL path. Accepts all of: +/// +/// - `/u//repos//graphs/` — current canonical form. +/// - `///{paths|graphs}/` — short SvelteKit route. +/// - `///` — bare triple. +/// +/// The trailing segment must parse as a UUID (the only addressing +/// scheme Pathbase 1.1+ accepts for graphs). +#[cfg(not(target_os = "emscripten"))] +fn extract_triple(segs: &[&str]) -> Option { + let n = segs.len(); + if n < 3 { + return None; + } + let id = segs[n - 1]; + if uuid::Uuid::parse_str(id).is_err() { + return None; + } + + // Look back through the canonical layouts in order of specificity. + let (owner, repo) = + if n >= 6 && segs[n - 6] == "u" && segs[n - 4] == "repos" && segs[n - 2] == "graphs" { + (segs[n - 5], segs[n - 3]) + } else if n >= 4 && (segs[n - 2] == "paths" || segs[n - 2] == "graphs") { + (segs[n - 4], segs[n - 3]) + } else { + (segs[n - 3], segs[n - 2]) + }; + + if owner.is_empty() || repo.is_empty() { + return None; + } + Some(PathRef { + owner: owner.to_string(), + repo: repo.to_string(), + id: id.to_string(), + }) +} + +/// Extract the inner identifier from a graph (without source prefix). +pub(crate) fn doc_inner_id(doc: &Graph) -> String { + doc.graph.id.clone() +} + +#[cfg(all(test, not(target_os = "emscripten")))] +mod tests { + use super::*; + + const UUID: &str = "fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537"; + + #[test] + fn parse_pathbase_ref_full_url_canonical_form() { + let url = format!("https://pathbase.dev/u/alex/repos/pathstash/graphs/{UUID}"); + let (base, ref_) = parse_pathbase_ref(&url, None).unwrap(); + assert_eq!(base.as_deref(), Some("https://pathbase.dev")); + assert_eq!( + ref_, + PathRef { + owner: "alex".into(), + repo: "pathstash".into(), + id: UUID.into(), + } + ); + } + + #[test] + fn parse_pathbase_ref_bare_triple_with_url_flag() { + let target = format!("alex/pathstash/{UUID}"); + let (base, ref_) = parse_pathbase_ref(&target, Some("https://other.example/")).unwrap(); + assert_eq!(base.as_deref(), Some("https://other.example")); + assert_eq!( + ref_, + PathRef { + owner: "alex".into(), + repo: "pathstash".into(), + id: UUID.into(), + } + ); + } + + #[test] + fn parse_pathbase_ref_bare_triple_no_flag() { + let target = format!("alex/pathstash/{UUID}"); + let (base, ref_) = parse_pathbase_ref(&target, None).unwrap(); + assert_eq!(base, None); + assert_eq!( + ref_, + PathRef { + owner: "alex".into(), + repo: "pathstash".into(), + id: UUID.into(), + } + ); + } + + #[test] + fn parse_pathbase_ref_url_with_trailing_slash() { + let url = format!("https://pathbase.dev/alex/pathstash/{UUID}/"); + let (base, ref_) = parse_pathbase_ref(&url, None).unwrap(); + assert_eq!(base.as_deref(), Some("https://pathbase.dev")); + assert_eq!(ref_.id, UUID); + } + + #[test] + fn parse_pathbase_ref_short_route_with_graphs_delimiter() { + let url = format!("https://pathbase.dev/alex/pathstash/graphs/{UUID}"); + let (_, ref_) = parse_pathbase_ref(&url, None).unwrap(); + assert_eq!( + ref_, + PathRef { + owner: "alex".into(), + repo: "pathstash".into(), + id: UUID.into(), + } + ); + } + + #[test] + fn parse_pathbase_ref_legacy_paths_delimiter_still_parses() { + // Pre-1.1 share URLs used `///paths/`. Keep + // parsing them for back-compat — `id` still has to be a UUID + // because that's the only addressing scheme the new server + // understands; legacy slug-style refs no longer resolve. + let url = format!("https://pathbase.dev/anon/pathstash/paths/{UUID}"); + let (_, ref_) = parse_pathbase_ref(&url, None).unwrap(); + assert_eq!( + ref_, + PathRef { + owner: "anon".into(), + repo: "pathstash".into(), + id: UUID.into(), + } + ); + } + + #[test] + fn parse_pathbase_ref_rejects_non_uuid_trailing_segment() { + // Pathbase 1.1+ addresses graphs by UUID; a slug-style ref + // can no longer be resolved, so fail at the parse step. + assert!(parse_pathbase_ref("alex/pathstash/my-path", None).is_err()); + assert!(parse_pathbase_ref("https://pathbase.dev/alex/pathstash/my-path", None).is_err()); + } + + #[test] + fn parse_pathbase_ref_rejects_too_few_segments() { + assert!(parse_pathbase_ref("https://pathbase.dev/just-one", None).is_err()); + assert!(parse_pathbase_ref("just/two", None).is_err()); + } + + #[test] + #[cfg(not(target_os = "emscripten"))] + fn pathbase_fetch_to_doc_url_input() { + use crate::cmd_pathbase::tests::MockServer; + let body = r#"{"graph":{"id":"g1"},"paths":[{"path":{"id":"p1","head":"s1"},"steps":[{"step":{"id":"s1","actor":"agent:claude-code","timestamp":"2026-01-01T00:00:00Z"},"change":{}}]}]}"#; + let server = MockServer::start("HTTP/1.1 200 OK", body); + let url = format!("{}/u/alex/repos/pathstash/graphs/{UUID}", server.base()); + + let derived = pathbase_fetch_to_doc(&url, None).unwrap(); + + assert_eq!(derived.cache_id, format!("pathbase-alex-pathstash-{UUID}")); + assert!(derived.doc.into_single_path().is_some()); + } +} diff --git a/crates/path-cli/src/fuzzy.rs b/crates/path-cli/src/fuzzy.rs index 71ea457b..d37f7848 100644 --- a/crates/path-cli/src/fuzzy.rs +++ b/crates/path-cli/src/fuzzy.rs @@ -175,7 +175,7 @@ pub(crate) fn count(n: usize, unit: &str) -> String { /// Layout: `[{leading} ]{when:16} {count} [{project:28}] {title}`. /// /// - `leading` is an optional prefix (used by `path share` to inject a -/// cwd-match marker and a harness symbol; `None` for single-provider +/// cwd-match marker and a padded harness name; `None` for single-provider /// `path p import ` pickers where neither applies). /// - `when` renders as `YYYY-MM-DD HH:MM` or a 16-char placeholder when /// the provider doesn't have a timestamp on hand. @@ -675,7 +675,7 @@ mod tests { assert_eq!(out, "2026-01-29 10:05 17 msgs hello world"); } - /// `path share` injects a scope marker + harness symbol as the + /// `path share` injects a scope marker + padded harness name as the /// `leading` prefix; assert it's separated from the timestamp by /// exactly one space so the layout is grep-able. #[test] diff --git a/crates/path-cli/src/harness.rs b/crates/path-cli/src/harness.rs new file mode 100644 index 00000000..490e5455 --- /dev/null +++ b/crates/path-cli/src/harness.rs @@ -0,0 +1,157 @@ +//! The harness layer over [`ArtifactType`]: [`Harness`] names the +//! agent runtimes sessions can be shared from and resumed into, and +//! `HarnessBundle` carries one provider manager per installed harness +//! for commands that aggregate across them. + +use crate::artifact::ArtifactType; + +/// An installed agent harness — a runtime sessions can be shared from +/// and resumed into. Deliberately parallel to [`ArtifactType`]: every +/// harness maps onto an artifact type, but not every artifact type is +/// a harness once non-session kinds (git, github) join `ArtifactType` +/// — you can't resume into a git repo. Harness-facing surfaces +/// (`share`/`resume` `--harness`) take this type so non-harness values +/// stay unrepresentable. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, clap::ValueEnum)] +#[value(rename_all = "lower")] +pub enum Harness { + Claude, + Gemini, + Codex, + Opencode, + Cursor, + Pi, + Copilot, +} + +impl Harness { + /// Every harness, in presentation order. + pub(crate) const ALL: [Harness; 7] = [ + Harness::Claude, + Harness::Gemini, + Harness::Codex, + Harness::Opencode, + Harness::Cursor, + Harness::Pi, + Harness::Copilot, + ]; + + /// The artifact type this harness's sessions ingest as. + pub(crate) fn artifact_type(&self) -> ArtifactType { + match self { + Harness::Claude => ArtifactType::Claude, + Harness::Gemini => ArtifactType::Gemini, + Harness::Codex => ArtifactType::Codex, + Harness::Opencode => ArtifactType::Opencode, + Harness::Cursor => ArtifactType::Cursor, + Harness::Pi => ArtifactType::Pi, + Harness::Copilot => ArtifactType::Copilot, + } + } + + pub(crate) fn name(&self) -> &'static str { + self.artifact_type().name() + } + + pub(crate) fn padded_name(&self) -> String { + self.artifact_type().padded_name() + } +} + +impl ArtifactType { + /// The harness this artifact type's sessions come from — `None` + /// for artifact types that aren't agent harnesses (none yet). + pub(crate) fn harness(&self) -> Option { + match self { + ArtifactType::Claude => Some(Harness::Claude), + ArtifactType::Gemini => Some(Harness::Gemini), + ArtifactType::Codex => Some(Harness::Codex), + ArtifactType::Opencode => Some(Harness::Opencode), + ArtifactType::Cursor => Some(Harness::Cursor), + ArtifactType::Pi => Some(Harness::Pi), + ArtifactType::Copilot => Some(Harness::Copilot), + ArtifactType::Git => None, + } + } +} + +/// Bundle of provider managers used during aggregation. Production code +/// builds this from real `$HOME` via `from_environment`; tests construct +/// it directly with provider-specific resolvers. +#[derive(Default)] +pub(crate) struct HarnessBundle { + pub(crate) claude: Option, + pub(crate) gemini: Option, + pub(crate) codex: Option, + pub(crate) copilot: Option, + pub(crate) opencode: Option, + pub(crate) cursor: Option, + pub(crate) pi: Option, +} + +impl HarnessBundle { + /// Build the production bundle. Each provider is included + /// unconditionally (its `new()` doesn't fail on a missing home dir); + /// consumers skip the ones whose listing returns empty/NotFound. + pub(crate) fn from_environment() -> Self { + Self { + claude: Some(toolpath_claude::ClaudeConvo::new()), + gemini: Some(toolpath_gemini::GeminiConvo::new()), + codex: Some(toolpath_codex::CodexConvo::new()), + copilot: Some(toolpath_copilot::CopilotConvo::new()), + opencode: Some(toolpath_opencode::OpencodeConvo::new()), + cursor: Some(toolpath_cursor::CursorConvo::new()), + pi: Some(toolpath_pi::PiConvo::new()), + } + } +} + +pub(crate) fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { + use toolpath_claude::ConvoError; + matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, ConvoError::NoHomeDirectory) + || matches!(err, ConvoError::ClaudeDirectoryNotFound(_)) +} + +pub(crate) fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool { + use toolpath_gemini::ConvoError; + matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, ConvoError::NoHomeDirectory) + || matches!(err, ConvoError::GeminiDirectoryNotFound(_)) +} + +pub(crate) fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool { + use toolpath_pi::PiError; + matches!(err, PiError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, PiError::ProjectNotFound(_)) +} + +pub(crate) fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { + use toolpath_codex::ConvoError; + matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, ConvoError::NoHomeDirectory) + || matches!(err, ConvoError::CodexDirectoryNotFound(_)) +} + +pub(crate) fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool { + use toolpath_copilot::ConvoError; + matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, ConvoError::NoHomeDirectory) + || matches!(err, ConvoError::CopilotDirectoryNotFound(_)) +} + +pub(crate) fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { + use toolpath_opencode::ConvoError; + matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, ConvoError::NoHomeDirectory) + || matches!(err, ConvoError::OpencodeDirectoryNotFound(_)) + || matches!(err, ConvoError::DatabaseNotFound(_)) +} + +pub(crate) fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { + use toolpath_cursor::CursorError; + matches!(err, CursorError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) + || matches!(err, CursorError::NoHomeDirectory) + || matches!(err, CursorError::CursorDataDirectoryNotFound(_)) + || matches!(err, CursorError::DatabaseNotFound(_)) +} diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 784cd337..cd364afa 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -1,3 +1,5 @@ +pub mod artifact; +mod cache; #[cfg(not(target_os = "emscripten"))] mod cmd_auth; mod cmd_cache; @@ -25,8 +27,11 @@ mod cmd_show; mod cmd_track; mod cmd_validate; mod config; +mod derive; #[cfg(not(target_os = "emscripten"))] mod fuzzy; +#[cfg(not(target_os = "emscripten"))] +pub mod harness; mod io; mod kinds; mod query; diff --git a/crates/path-cli/src/query/mod.rs b/crates/path-cli/src/query/mod.rs index 6d5d2cfc..08e079db 100644 --- a/crates/path-cli/src/query/mod.rs +++ b/crates/path-cli/src/query/mod.rs @@ -147,7 +147,7 @@ fn select_files(scope: &Scope) -> Result> { // dropped. A `--source`/default scan is not explicit (skip-warn). let by_id = id_set.is_some(); let mut seen_ids: HashSet = HashSet::new(); - for entry in crate::cmd_cache::list_cached()? { + for entry in crate::cache::list_cached()? { if let Some(ids) = &id_set && !ids.contains(entry.id.as_str()) { diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 375b491a..9f2709fe 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -658,6 +658,92 @@ fn cache_ls_after_import_lists_entry() { .stdout(predicate::str::contains("git-")); } +/// A `$HOME` with one Claude session. Returns (home-tempdir, session file). +fn claude_home_fixture() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("proj"); + std::fs::create_dir_all(&project).unwrap(); + // toolpath-claude maps '/', '_', and '.' to '-' when sanitizing project + // paths into directory slugs — mirror that here so the fixture lands + // where the resolver looks for it. + let project_slug = project + .to_string_lossy() + .replace([std::path::MAIN_SEPARATOR, '_', '.'], "-"); + let project_dir = temp.path().join(".claude/projects").join(&project_slug); + std::fs::create_dir_all(&project_dir).unwrap(); + let session_file = project_dir.join("session-abc.jsonl"); + std::fs::write( + &session_file, + 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(); + (temp, session_file) +} + +#[test] +fn import_ingests_thinking_maximally() { + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // Append an assistant turn with a thinking block. + let mut body = std::fs::read_to_string(&session_file).unwrap(); + body.push_str( + r#"{"type":"assistant","uuid":"a-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"secret reasoning"},{"type":"text","text":"done"}]}}"#, + ); + body.push('\n'); + std::fs::write(&session_file, body).unwrap(); + + let out = cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args([ + "p", + "import", + "claude", + "--session", + "session-abc", + "--project", + ]) + .arg(&project) + .output() + .unwrap(); + assert!(out.status.success()); + let doc_path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let doc = std::fs::read_to_string(&doc_path).unwrap(); + assert!( + doc.contains("secret reasoning"), + "the cache holds the maximal derivation, thinking included" + ); +} + +#[cfg(unix)] +#[test] +fn bulk_import_skips_unreadable_sessions() { + use std::os::unix::fs::PermissionsExt; + let (home, session_file) = claude_home_fixture(); + let cfg = tempfile::tempdir().unwrap(); + let project = home.path().join("proj"); + // A second session that cannot be read. + let bad = session_file.parent().unwrap().join("deadbeef-bad.jsonl"); + std::fs::write(&bad, "x").unwrap(); + std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o000)).unwrap(); + + cmd() + .env("HOME", home.path()) + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["p", "import", "claude", "--all", "--project"]) + .arg(&project) + .assert() + .success() + .stderr(predicate::str::contains("Warning: skipping session")) + .stderr(predicate::str::contains("Imported")); +} + #[test] fn export_pathbase_repo_flag_requires_login() { // `export pathbase` without --repo falls through to the anonymous diff --git a/crates/path-cli/tests/query.rs b/crates/path-cli/tests/query.rs index 68525567..f32f7509 100644 --- a/crates/path-cli/tests/query.rs +++ b/crates/path-cli/tests/query.rs @@ -10,7 +10,14 @@ use predicates::prelude::*; use std::path::Path; fn cmd() -> Command { - Command::cargo_bin("path").unwrap() + // `path query` auto-syncs the cache from the installed harnesses; + // pin $HOME to a shared empty sandbox so tests exercise that path + // without ingesting the developer's real sessions. + static HOME: std::sync::OnceLock = std::sync::OnceLock::new(); + let home = HOME.get_or_init(|| tempfile::tempdir().unwrap()); + let mut c = Command::cargo_bin("path").unwrap(); + c.env("HOME", home.path()).env_remove("XDG_DATA_HOME"); + c } /// Write `json` into `/documents/.json`, creating the dir. @@ -504,3 +511,5 @@ fn kind_unknown_errors() { .failure() .stderr(predicate::str::contains("Bundled kinds")); } + +// ── auto-sync on invocation ────────────────────────────────────────── diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 32cac640..f751c40e 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -8,7 +8,8 @@ #![cfg(not(target_os = "emscripten"))] -use path_cli::cmd_resume::{HarnessArg, RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::cmd_resume::{RecordingExec, ResumeArgs, run_with_strategy}; +use path_cli::harness::Harness; mod support; use support::*; @@ -27,7 +28,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap(); @@ -61,7 +62,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Gemini), + args_explicit(doc_file, cwd.path(), Harness::Gemini), &recorder, ) .unwrap(); @@ -89,7 +90,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Codex), + args_explicit(doc_file, cwd.path(), Harness::Codex), &recorder, ) .unwrap(); @@ -117,7 +118,7 @@ fn file_input_explicit_copilot_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Copilot), + args_explicit(doc_file, cwd.path(), Harness::Copilot), &recorder, ) .unwrap(); @@ -191,7 +192,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { let recorder = RecordingExec::default(); run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Opencode), + args_explicit(doc_file, cwd.path(), Harness::Opencode), &recorder, ) .unwrap(); @@ -219,11 +220,7 @@ fn file_input_explicit_pi_projects_and_records_exec() { let doc_file = write_path_to_temp(cwd.path(), path); let recorder = RecordingExec::default(); - run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Pi), - &recorder, - ) - .unwrap(); + run_with_strategy(args_explicit(doc_file, cwd.path(), Harness::Pi), &recorder).unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "pi"); @@ -264,7 +261,7 @@ fn cache_id_input_loads_and_projects() { let resume_args = ResumeArgs { input: cache_id.to_string(), cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), + harness: Some(Harness::Claude), no_cache: false, force: false, url: None, @@ -304,7 +301,7 @@ fn multi_path_graph_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); @@ -326,7 +323,7 @@ fn agentless_path_returns_clear_error() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); @@ -345,7 +342,7 @@ fn explicit_harness_not_on_path_errors() { let recorder = RecordingExec::default(); let err = run_with_strategy( - args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, ) .unwrap_err(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 578b5af1..bf7597ba 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -11,7 +11,8 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; -use path_cli::cmd_resume::{HarnessArg, ResumeArgs}; +use path_cli::cmd_resume::ResumeArgs; +use path_cli::harness::Harness; /// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or /// `$TOOLPATH_CONFIG_DIR`. Integration tests under `tests/resume.rs` @@ -178,7 +179,7 @@ pub fn write_path_to_temp(dir: &Path, path: toolpath::v1::Path) -> PathBuf { } /// Construct `ResumeArgs` for a file-input + explicit-harness test. -pub fn args_explicit(input: PathBuf, cwd: &Path, harness: HarnessArg) -> ResumeArgs { +pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs { ResumeArgs { input: input.to_string_lossy().to_string(), cwd: Some(cwd.to_path_buf()), diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index 5f463412..0ed4b496 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.0" +version = "0.12.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index 4d70a3d9..7251769e 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -340,13 +340,14 @@ impl ClaudeConvo { /// Resolves the full session chain containing `session_id`, returned /// in chronological order (oldest segment first). /// - /// For single-segment sessions, returns `[session_id]`. - #[allow(dead_code)] - pub(crate) fn session_chain( - &self, - project_path: &str, - session_id: &str, - ) -> Result> { + /// For single-segment sessions, returns `[session_id]`. This is the + /// set of files [`Self::read_conversation`] would merge — appends + /// land in the *last* segment while the chain keeps the first + /// segment's id, so anything fingerprinting a conversation must + /// stat every segment, not the head file alone. Uses the same + /// cached chain index as the other chain surfaces; after a + /// [`Self::list_conversations`] call it costs no extra IO. + pub fn session_chain(&self, project_path: &str, session_id: &str) -> Result> { self.chain_for(project_path, session_id) } diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index dd3ddc7f..54cf9fe6 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.15.0" +version = "0.16.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.15.0" } +path-cli = { path = "../path-cli", version = "0.16.0" } anyhow = "1.0" [workspace] diff --git a/crates/toolpath-codex/Cargo.toml b/crates/toolpath-codex/Cargo.toml index edf70bbf..693636e3 100644 --- a/crates/toolpath-codex/Cargo.toml +++ b/crates/toolpath-codex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-codex" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-codex/src/io.rs b/crates/toolpath-codex/src/io.rs index 4c39dbf0..25441a9a 100644 --- a/crates/toolpath-codex/src/io.rs +++ b/crates/toolpath-codex/src/io.rs @@ -39,6 +39,18 @@ impl ConvoIO { self.resolver.list_rollout_files() } + /// List every session id (the rollout filename stem, which + /// [`Self::read_session`] resolves without a tree walk), newest + /// first. One directory walk, no file reads — unlike + /// [`Self::list_sessions`], which parses every file for metadata. + pub fn list_session_ids(&self) -> Result> { + Ok(self + .list_rollout_files()? + .iter() + .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from)) + .collect()) + } + /// Return lightweight metadata for every rollout, newest first. pub fn list_sessions(&self) -> Result> { let files = self.list_rollout_files()?; @@ -154,6 +166,24 @@ mod tests { assert_eq!(sessions[0].cli_version.as_deref(), Some("0.118.0")); } + /// Ids come from filenames alone, so even a file whose body would + /// fail to parse is listed; the failure surfaces on read instead. + #[test] + fn list_session_ids_returns_stems_without_reading_bodies() { + let (_t, io) = setup(); + let day = io.resolver().sessions_root().unwrap().join("2026/04/21"); + fs::create_dir_all(&day).unwrap(); + fs::write(day.join("rollout-2026-04-21T09-00-00-bbb.jsonl"), "not json").unwrap(); + + let ids = io.list_session_ids().unwrap(); + assert_eq!(ids.len(), 2); + assert!(ids.contains(&"rollout-2026-04-20T10-00-00-019dabc6-aaa".to_string())); + assert!(ids.contains(&"rollout-2026-04-21T09-00-00-bbb".to_string())); + for id in &ids { + assert!(io.read_session(id).is_ok() || id.contains("bbb")); + } + } + #[test] fn read_session_by_id() { let (_t, io) = setup(); diff --git a/crates/toolpath-codex/src/paths.rs b/crates/toolpath-codex/src/paths.rs index 282401b4..7ca50b51 100644 --- a/crates/toolpath-codex/src/paths.rs +++ b/crates/toolpath-codex/src/paths.rs @@ -98,9 +98,14 @@ impl PathResolver { /// /// Accepts: /// - A full filename stem: `rollout-2026-04-20T12-43-30-019dabc6-8fef-7681-a054-b5bb75fcb97d` + /// (resolved without walking the tree — the stem's date names its + /// `YYYY/MM/DD` bucket) /// - A bare session UUID (suffix match): `019dabc6-8fef-7681-a054-b5bb75fcb97d` /// - A short UUID prefix: `019dabc6` (resolves if unique) pub fn find_rollout_file(&self, session_id: &str) -> Result { + if let Some(direct) = self.rollout_file_for_stem(session_id)? { + return Ok(direct); + } let all = self.list_rollout_files()?; // Exact filename stem match first. for p in &all { @@ -130,6 +135,30 @@ impl PathResolver { ))), } } + + /// Direct lookup for a full filename stem: `rollout-T…` + /// lives at `sessions/YYYY/MM/DD/.jsonl`. `None` when the id + /// isn't stem-shaped or the file isn't at its dated path (the caller + /// falls back to the tree walk). + fn rollout_file_for_stem(&self, session_id: &str) -> Result> { + let Some(date) = session_id.strip_prefix("rollout-").and_then(|r| r.get(..10)) else { + return Ok(None); + }; + let parts: Vec<&str> = date.split('-').collect(); + let [y, m, d] = parts.as_slice() else { + return Ok(None); + }; + if y.len() != 4 || m.len() != 2 || d.len() != 2 { + return Ok(None); + } + let candidate = self + .sessions_root()? + .join(y) + .join(m) + .join(d) + .join(format!("{session_id}.jsonl")); + Ok(candidate.is_file().then_some(candidate)) + } } /// Recursively collect `rollout-*.jsonl` files under `root`. @@ -267,6 +296,19 @@ mod tests { assert!(p.exists()); } + /// A stem whose date doesn't match the directory it sits in misses + /// the direct dated-path lookup and must still resolve via the walk. + #[test] + fn find_rollout_stem_in_mismatched_date_dir_falls_back_to_walk() { + let (_t, r) = setup(); + let day = r.sessions_root().unwrap().join("2026/04/21"); + fs::create_dir_all(&day).unwrap(); + let stem = "rollout-2026-04-20T10-00-00-abc-xyz"; + fs::write(day.join(format!("{}.jsonl", stem)), "{}").unwrap(); + let p = r.find_rollout_file(stem).unwrap(); + assert_eq!(p.file_stem().unwrap(), stem); + } + #[test] fn find_rollout_missing_errors() { let (_t, r) = setup(); diff --git a/crates/toolpath-codex/src/provider.rs b/crates/toolpath-codex/src/provider.rs index 6f9c4003..5b915ce2 100644 --- a/crates/toolpath-codex/src/provider.rs +++ b/crates/toolpath-codex/src/provider.rs @@ -81,6 +81,11 @@ impl CodexConvo { self.io.list_sessions() } + /// List every session id, newest first, without reading any files. + pub fn list_session_ids(&self) -> crate::Result> { + self.io.list_session_ids() + } + /// Most recent session (by last activity), if any. pub fn most_recent_session(&self) -> crate::Result> { let metas = self.list_sessions()?; diff --git a/crates/toolpath-gemini/Cargo.toml b/crates/toolpath-gemini/Cargo.toml index 76fdeedc..b5b09f3e 100644 --- a/crates/toolpath-gemini/Cargo.toml +++ b/crates/toolpath-gemini/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-gemini" -version = "0.6.0" +version = "0.6.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-gemini/src/lib.rs b/crates/toolpath-gemini/src/lib.rs index 10891df1..09ab8b5a 100644 --- a/crates/toolpath-gemini/src/lib.rs +++ b/crates/toolpath-gemini/src/lib.rs @@ -15,7 +15,7 @@ pub mod watcher; pub use error::{ConvoError, Result}; pub use io::ConvoIO; -pub use paths::PathResolver; +pub use paths::{PathResolver, SessionEntry}; pub use query::ConversationQuery; pub use reader::ConversationReader; pub use types::{ diff --git a/crates/toolpath-gemini/src/paths.rs b/crates/toolpath-gemini/src/paths.rs index 09bb50e7..561811ef 100644 --- a/crates/toolpath-gemini/src/paths.rs +++ b/crates/toolpath-gemini/src/paths.rs @@ -18,6 +18,20 @@ const TMP_DIR: &str = "tmp"; const CHATS_SUBDIR: &str = "chats"; const LOGS_FILE: &str = "logs.json"; +/// One session surfaced by [`PathResolver::list_session_entries`]. +#[derive(Debug, Clone)] +pub struct SessionEntry { + /// Listing key, exactly as [`PathResolver::list_sessions`] returns + /// it: main-file stem or orphan sub-agent directory name. + pub id: String, + /// Inner `sessionId` UUID peeked from a main file (the directory + /// name itself for orphan dirs); `None` when the peek failed. + pub session_uuid: Option, + /// The main chat file, or the orphan sub-agent directory — stat + /// this for change detection. + pub path: PathBuf, +} + #[derive(Debug, Clone)] pub struct PathResolver { home_dir: Option, @@ -194,6 +208,20 @@ impl PathResolver { /// sub-agent bucket and is **not** surfaced as a separate session — /// it gets merged into the main session by `read_session`. pub fn list_sessions(&self, project_path: &str) -> Result> { + Ok(self + .list_session_entries(project_path)? + .into_iter() + .map(|e| e.id) + .collect()) + } + + /// Like [`Self::list_sessions`], but each session comes with the + /// backing main file (or orphan sub-agent directory) and the inner + /// `sessionId` when one could be peeked — enough for stat-level + /// change detection without parsing chat bodies. The peek is + /// bounded (see `peek_session_id`): it scans a fixed-size prefix + /// and falls back to a full parse only when identity isn't there. + pub fn list_session_entries(&self, project_path: &str) -> Result> { let chats = match self.chats_dir(project_path) { Ok(p) => p, Err(_) => return Ok(Vec::new()), @@ -202,9 +230,9 @@ impl PathResolver { return Ok(Vec::new()); } - let mut main_stems: Vec = Vec::new(); + let mut mains: Vec = Vec::new(); let mut main_session_uuids: std::collections::HashSet = Default::default(); - let mut dir_uuids: Vec = Vec::new(); + let mut dirs: Vec = Vec::new(); for entry in fs::read_dir(&chats)?.flatten() { let ft = match entry.file_type() { @@ -220,24 +248,33 @@ impl PathResolver { Some(s) => s.to_string(), None => continue, }; - main_stems.push(stem); - if let Some(uuid) = peek_session_id(&path) { - main_session_uuids.insert(uuid); + let session_uuid = peek_session_id(&path); + if let Some(uuid) = &session_uuid { + main_session_uuids.insert(uuid.clone()); } + mains.push(SessionEntry { + id: stem, + session_uuid, + path, + }); } else if ft.is_dir() && let Some(name) = entry.file_name().to_str() { - dir_uuids.push(name.to_string()); + dirs.push(SessionEntry { + id: name.to_string(), + session_uuid: Some(name.to_string()), + path, + }); } } - let mut out = main_stems; - for uuid in dir_uuids { - if !main_session_uuids.contains(&uuid) { - out.push(uuid); + let mut out = mains; + for dir in dirs { + if !main_session_uuids.contains(&dir.id) { + out.push(dir); } } - out.sort(); + out.sort_by(|a, b| a.id.cmp(&b.id)); Ok(out) } @@ -351,10 +388,26 @@ struct ProjectsFile { projects: HashMap, } -/// Read just the top-level `sessionId` field from a chat JSON file -/// without materialising the whole document. Used by `list_sessions` to -/// correlate main files with sibling sub-agent UUID directories. +/// Byte budget for [`peek_session_id`]'s prefix read. Chat files put +/// their identity fields first, so this is plenty in practice. +const PEEK_BYTES: usize = 4096; + +/// Read just the top-level `sessionId` field from a chat JSON file. +/// Bounded: scans the first [`PEEK_BYTES`] of the file and falls back +/// to a full parse only when the field isn't in the prefix. Used by +/// `list_session_entries` to correlate main files with sibling +/// sub-agent UUID directories. fn peek_session_id(path: &std::path::Path) -> Option { + use std::io::Read; + let file = fs::File::open(path).ok()?; + let mut prefix = Vec::with_capacity(PEEK_BYTES); + file.take(PEEK_BYTES as u64).read_to_end(&mut prefix).ok()?; + if let Some(id) = prefix_session_id(&prefix) { + return Some(id); + } + // The prefix scan can *decline* (identity after `messages`) as well + // as miss, so always fall back to the full parse — for files that + // fit in the prefix this re-reads a few KiB, which is noise. #[derive(Deserialize)] struct Peek { #[serde(rename = "sessionId")] @@ -365,6 +418,31 @@ fn peek_session_id(path: &std::path::Path) -> Option { peek.session_id.filter(|s| !s.is_empty()) } +/// Extract `"sessionId": "…"` from a JSON prefix, trusting it only when +/// it appears before any `"messages"` key — message bodies are the one +/// place user-controlled text could fake the key. +fn prefix_session_id(prefix: &[u8]) -> Option { + let text = match std::str::from_utf8(prefix) { + Ok(t) => t, + // The cut can land mid-codepoint; scan the valid part. + Err(e) => std::str::from_utf8(&prefix[..e.valid_up_to()]).ok()?, + }; + let key_at = text.find("\"sessionId\"")?; + if let Some(messages_at) = text.find("\"messages\"") + && messages_at < key_at + { + return None; + } + let rest = text[key_at + "\"sessionId\"".len()..].trim_start(); + let rest = rest.strip_prefix(':')?.trim_start(); + let rest = rest.strip_prefix('"')?; + let value = &rest[..rest.find('"')?]; + if value.is_empty() || value.contains('\\') { + return None; + } + Some(value.to_string()) +} + /// Canonical `projectHash`: SHA-256 hex of the absolute project path. pub fn project_hash(project_path: &str) -> String { let mut hasher = Sha256::new(); @@ -793,4 +871,82 @@ mod tests { assert!(!sessions.contains(&"sess-uuid-full".to_string())); assert_eq!(sessions.len(), 2); } + + #[test] + fn peek_session_id_reads_id_from_prefix_of_large_file() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let pad = "x".repeat(16 * 1024); + let body = format!( + r#"{{"sessionId":"aaaa-bbbb","projectHash":"h","messages":[{{"content":"{pad}"}}]}}"# + ); + let path = chats.join("session-2026-01-01T00-00-aaaa.json"); + fs::write(&path, body).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("aaaa-bbbb")); + } + + #[test] + fn peek_session_id_falls_back_when_identity_comes_late() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let pad = "x".repeat(16 * 1024); + let body = format!(r#"{{"messages":[{{"content":"{pad}"}}],"sessionId":"late-id"}}"#); + let path = chats.join("session-2026-01-01T00-00-late.json"); + fs::write(&path, body).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("late-id")); + } + + #[test] + fn peek_session_id_small_file_with_late_identity_still_resolves() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + // Fits inside the prefix, but the prefix scan must decline + // (identity after `messages`) and the serde fallback must win. + let path = chats.join("session-2026-01-01T00-00-tiny.json"); + fs::write(&path, r#"{"messages":[],"sessionId":"tiny-id"}"#).unwrap(); + assert_eq!(peek_session_id(&path).as_deref(), Some("tiny-id")); + } + + #[test] + fn prefix_session_id_rejects_keys_after_messages() { + assert_eq!( + prefix_session_id(br#"{"sessionId":"abc","messages":[]}"#).as_deref(), + Some("abc") + ); + assert_eq!( + prefix_session_id(br#"{"messages":[],"sessionId":"abc"}"#), + None, + "a sessionId after the messages key must not be trusted from the prefix" + ); + assert_eq!(prefix_session_id(br#"{"sessionId":""}"#), None); + } + + #[test] + fn list_session_entries_pairs_ids_with_backing_paths() { + let (_temp, resolver) = setup(); + let chats = resolver.chats_dir("/proj").unwrap(); + fs::create_dir_all(&chats).unwrap(); + let main = chats.join("session-2026-01-01T00-00-aaaa.json"); + fs::write(&main, r#"{"sessionId":"uuid-a","messages":[]}"#).unwrap(); + // uuid-a's sub-agent bucket is claimed by the main file; uuid-b + // is an orphan and must surface as its own session. + fs::create_dir_all(chats.join("uuid-a")).unwrap(); + fs::create_dir_all(chats.join("uuid-b")).unwrap(); + + let entries = resolver.list_session_entries("/proj").unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id, "session-2026-01-01T00-00-aaaa"); + assert_eq!(entries[0].session_uuid.as_deref(), Some("uuid-a")); + assert_eq!(entries[0].path, main); + assert_eq!(entries[1].id, "uuid-b"); + assert_eq!(entries[1].session_uuid.as_deref(), Some("uuid-b")); + assert_eq!(entries[1].path, chats.join("uuid-b")); + + // The plain listing keeps returning the same ids. + let ids = resolver.list_sessions("/proj").unwrap(); + assert_eq!(ids, vec!["session-2026-01-01T00-00-aaaa", "uuid-b"]); + } } diff --git a/site/_data/crates.json b/site/_data/crates.json index dc86dffe..99bcad0b 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.0", + "version": "0.12.1", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", @@ -41,7 +41,7 @@ }, { "name": "toolpath-gemini", - "version": "0.6.0", + "version": "0.6.1", "description": "Derive from Gemini CLI conversation logs", "docs": "https://docs.rs/toolpath-gemini", "crate": "https://crates.io/crates/toolpath-gemini", @@ -49,7 +49,7 @@ }, { "name": "toolpath-codex", - "version": "0.6.0", + "version": "0.6.1", "description": "Derive from Codex CLI rollout files", "docs": "https://docs.rs/toolpath-codex", "crate": "https://crates.io/crates/toolpath-codex", @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.15.0", + "version": "0.16.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.15.0", + "version": "0.16.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli",