diff --git a/CHANGELOG.md b/CHANGELOG.md index baadd838..ee12a3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,40 @@ cache the same queries run ~4.7× faster (e.g. `length` 966 ms → drivers so the zero-file rule lives once. - The emscripten (playground) build keeps the sequential engine — no threads there. +## `toolpath-copilot`: the caller supplies the home directory — 2026-08-14 + +- **`toolpath-copilot`** (0.2.0): breaking. `PathResolver::new(home)` + takes the home directory as a required argument. The crate reads no + environment variable; it keeps the layout knowledge + (`/.copilot`) and the caller owns "what is home". + `CopilotConvo::new(home)` and `ConvoIO::new(home)` take the same + argument. + + Removed: the `Default` impls on `PathResolver`, `ConvoIO`, and + `CopilotConvo`; `PathResolver::with_home`; the `NoHomeDirectory` + error variant. `with_copilot_dir` stays as the full override, and it + wins against the home-derived default. + + The home directory is always present, so `home_dir()`, + `copilot_dir()`, `session_state_dir()`, + `legacy_session_state_dir()`, `session_store_db()`, and + `ConvoIO::copilot_dir_path()` return a path instead of a `Result`. + + Strict events parsing is a parameter. `CopilotConvo::with_strict(bool)` + and `ConvoIO::with_strict(bool)` set it, + `EventReader::read_lines_with(path, strict)` and + `EventReader::read_session_dir_with(dir, strict)` take it directly, + and `EventReader::read_lines(path)` stays lenient. The crate reads no + environment variable for it. +- **`path-cli`** (unreleased): `providers::copilot_resolver` returns + `Option`. `None` means the configuration carries no home + directory, so Copilot is out of reach: the harness bundle omits it, + and a command that targets Copilot reports "cannot determine the home + directory". `Config` reads `$COPILOT_EVENTS_STRICT` and passes the + flag to every `CopilotConvo` it builds, so the variable keeps its + behavior for CLI users. `$COPILOT_HOME` stays a `Config` read, and the + resolver receives it as the injected Copilot root. + ## `toolpath-claude`: the caller supplies the home directory — 2026-08-13 - **`toolpath-claude`** (0.13.0): breaking. `PathResolver::new(home)` diff --git a/Cargo.lock b/Cargo.lock index 492763fa..542e7179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4270,7 +4270,7 @@ dependencies = [ [[package]] name = "toolpath-copilot" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 626bfba3..4616347e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.7.0", path = "crates/toolpath-codex" } -toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } +toolpath-copilot = { version = "0.2.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" } toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 42a6e740..65d13d0e 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -493,10 +493,8 @@ fn run_copilot( #[cfg(not(target_os = "emscripten"))] fn write_into_copilot_project(session: &toolpath_copilot::Session, config: &Config) -> Result<()> { - let resolver = providers::copilot_resolver(config); - let state_dir = resolver - .session_state_dir() - .map_err(|e| anyhow::anyhow!("Cannot resolve ~/.copilot/session-state: {}", e))?; + let resolver = providers::require_copilot_resolver(config)?; + let state_dir = resolver.session_state_dir(); let sess_dir = state_dir.join(&session.id); std::fs::create_dir_all(&sess_dir).with_context(|| format!("create {}", sess_dir.display()))?; @@ -517,9 +515,7 @@ fn write_into_copilot_project(session: &toolpath_copilot::Session, config: &Conf .with_context(|| "write workspace.yaml")?; // session-store.db `sessions` row — the resume picker reads this index. - let db_path = resolver - .session_store_db() - .map_err(|e| anyhow::anyhow!("Cannot resolve session-store.db: {}", e))?; + let db_path = resolver.session_store_db(); let registration = register_copilot_session(&db_path, session); eprintln!( diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 94c66c37..d783f025 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -930,7 +930,8 @@ fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result fn derive_copilot(session: Option, all: bool, config: &Config) -> Result> { let manager = - toolpath_copilot::CopilotConvo::with_resolver(providers::copilot_resolver(config)); + toolpath_copilot::CopilotConvo::with_resolver(providers::require_copilot_resolver(config)?) + .with_strict(providers::copilot_strict(config)); let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], diff --git a/crates/path-cli/src/cmd_list.rs b/crates/path-cli/src/cmd_list.rs index c3a63f05..38e9f0fa 100644 --- a/crates/path-cli/src/cmd_list.rs +++ b/crates/path-cli/src/cmd_list.rs @@ -708,7 +708,9 @@ fn run_codex(fmt: ListFormat, config: &Config) -> Result<()> { // ── Copilot (preview) ───────────────────────────────────────────────────────── fn run_copilot(fmt: ListFormat, config: &Config) -> Result<()> { - let manager = providers::copilot_convo(config); + let manager = + toolpath_copilot::CopilotConvo::with_resolver(providers::require_copilot_resolver(config)?) + .with_strict(providers::copilot_strict(config)); let sessions = manager .list_sessions() .map_err(|e| anyhow::anyhow!("{}", e))?; diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index e48dc9cd..aab577ce 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -708,12 +708,10 @@ fn harness_status_copilot(bundle: &HarnessBundle, home: Option<&std::path::Path> let Some(mgr) = &bundle.copilot else { return HarnessStatus::unresolved(); }; - match mgr.resolver().session_state_dir() { - Ok(p) => HarnessStatus { - path: crate::config::home_relative(&p, home), - exists: p.exists(), - }, - Err(_) => HarnessStatus::unresolved(), + let p = mgr.resolver().session_state_dir(); + HarnessStatus { + path: crate::config::home_relative(&p, home), + exists: p.exists(), } } @@ -1148,7 +1146,7 @@ mod tests { fn copilot_only_bundle(home: &Path) -> HarnessBundle { let copilot_dir = home.join(".copilot"); std::fs::create_dir_all(&copilot_dir).unwrap(); - let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + let resolver = toolpath_copilot::PathResolver::new(home).with_copilot_dir(&copilot_dir); HarnessBundle { copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), ..Default::default() diff --git a/crates/path-cli/src/cmd_show.rs b/crates/path-cli/src/cmd_show.rs index c8d73ba8..5ceb7d21 100644 --- a/crates/path-cli/src/cmd_show.rs +++ b/crates/path-cli/src/cmd_show.rs @@ -154,7 +154,10 @@ fn derive_one(source: ShowSource, config: &Config) -> Result session, project: _, } => { - let manager = providers::copilot_convo(config); + let manager = toolpath_copilot::CopilotConvo::with_resolver( + providers::require_copilot_resolver(config)?, + ) + .with_strict(providers::copilot_strict(config)); let s = manager .read_session(&session) .map_err(|e| anyhow::anyhow!("{}", e))?; diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 4139819e..252be69b 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -61,6 +61,10 @@ pub struct Config { /// unparseable rollout line. Presence is the signal; the value is /// not read. pub(crate) codex_rollout_strict: Option, + /// `$COPILOT_EVENTS_STRICT`: the Copilot reader errors on a + /// malformed events line. Presence is the signal; the value is not + /// read. + pub(crate) copilot_events_strict: Option, /// `$COPILOT_HOME`: Copilot CLI session root override. pub(crate) copilot_home: Option, /// `$HOME`: config-root fallback and the harness resolvers' root. @@ -110,6 +114,7 @@ impl Config { ("APPDATA", "appdata"), ("CLAUDE_CLI_DEBUG", "claude_cli_debug"), ("CODEX_ROLLOUT_STRICT", "codex_rollout_strict"), + ("COPILOT_EVENTS_STRICT", "copilot_events_strict"), ("COPILOT_HOME", "copilot_home"), ("HOME", "home"), (PATHBASE_URL_ENV, "pathbase_url"), @@ -205,6 +210,7 @@ mod tests { jail.set_env("CLAUDE_CLI_DEBUG", "1"); jail.set_env("CODEX_ROLLOUT_STRICT", "1"); jail.set_env("XDG_DATA_HOME", "/home/jailed/.local/share"); + jail.set_env("COPILOT_EVENTS_STRICT", "1"); jail.set_env("COPILOT_HOME", "/home/jailed/.copilot"); jail.set_env("APPDATA", "/home/jailed/appdata"); jail.set_env(PATHBASE_URL_ENV, "https://pathbase.test"); @@ -217,6 +223,7 @@ mod tests { appdata: Some(PathBuf::from("/home/jailed/appdata")), claude_cli_debug: Some("1".to_string()), codex_rollout_strict: Some("1".to_string()), + copilot_events_strict: Some("1".to_string()), copilot_home: Some(PathBuf::from("/home/jailed/.copilot")), home: Some(PathBuf::from("/home/jailed")), pathbase_url: Some("https://pathbase.test".to_string()), diff --git a/crates/path-cli/src/derive.rs b/crates/path-cli/src/derive.rs index 2340c898..a523b1e3 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -201,7 +201,10 @@ pub(crate) fn derive_codex_session_with( /// Derive a single Copilot session given an explicit session id. pub(crate) fn derive_copilot_session(config: &Config, session: &str) -> Result { derive_copilot_session_with( - &toolpath_copilot::CopilotConvo::with_resolver(providers::copilot_resolver(config)), + &toolpath_copilot::CopilotConvo::with_resolver(providers::require_copilot_resolver( + config, + )?) + .with_strict(providers::copilot_strict(config)), session, ) } diff --git a/crates/path-cli/src/harness.rs b/crates/path-cli/src/harness.rs index 385533b2..c0c0c0e2 100644 --- a/crates/path-cli/src/harness.rs +++ b/crates/path-cli/src/harness.rs @@ -115,7 +115,6 @@ pub(crate) fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool { 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(_)) } diff --git a/crates/path-cli/src/providers.rs b/crates/path-cli/src/providers.rs index a60b514f..f45d6416 100644 --- a/crates/path-cli/src/providers.rs +++ b/crates/path-cli/src/providers.rs @@ -10,9 +10,11 @@ //! error for a command that targets one harness. //! //! opencode, copilot, and cursor (Windows) get their directory -//! injected, not just the home: their resolvers read `$XDG_DATA_HOME` -//! / `$COPILOT_HOME` / `$APPDATA` internally, and those reads win -//! against `with_home`. The injected directory wins against both. +//! injected, not just the home. `$COPILOT_HOME` replaces the whole +//! Copilot root, so the injected directory wins against the +//! home-derived default. The opencode and cursor resolvers read +//! `$XDG_DATA_HOME` / `$APPDATA` internally, and those reads win +//! against `with_home`; the injected directory wins against both. use crate::config::Config; #[cfg(not(target_os = "emscripten"))] @@ -66,15 +68,25 @@ pub(crate) fn codex_strict(config: &Config) -> bool { config.codex_rollout_strict.is_some() } -pub(crate) fn copilot_convo(config: &Config) -> toolpath_copilot::CopilotConvo { - let mut resolver = toolpath_copilot::PathResolver::new(); - if let Some(home) = config.home_dir() { - resolver = resolver.with_home(home); - } - if let Some(dir) = &config.copilot_home { - resolver = resolver.with_copilot_dir(dir); - } - toolpath_copilot::CopilotConvo::with_resolver(resolver) +pub(crate) fn copilot_resolver(config: &Config) -> Option { + config.home_dir().map(|home| { + let resolver = toolpath_copilot::PathResolver::new(home); + match &config.copilot_home { + Some(dir) => resolver.with_copilot_dir(dir), + None => resolver, + } + }) +} + +/// [`copilot_resolver`] for a command that targets Copilot. +pub(crate) fn require_copilot_resolver(config: &Config) -> Result { + copilot_resolver(config).ok_or_else(|| missing_home("Copilot")) +} + +/// The Copilot reader's strict flag. `$COPILOT_EVENTS_STRICT` is strict +/// when set, whatever its value. +pub(crate) fn copilot_strict(config: &Config) -> bool { + config.copilot_events_strict.is_some() } #[cfg(not(target_os = "emscripten"))] @@ -132,7 +144,9 @@ pub(crate) fn harness_bundle(config: &Config) -> HarnessBundle { codex: codex_resolver(config).map(|r| { toolpath_codex::CodexConvo::with_resolver(r).with_strict(codex_strict(config)) }), - copilot: Some(copilot_convo(config)), + copilot: copilot_resolver(config).map(|r| { + toolpath_copilot::CopilotConvo::with_resolver(r).with_strict(copilot_strict(config)) + }), opencode: Some(opencode_convo(config)), cursor: Some(cursor_convo(config)), pi: Some(pi_convo(config, None)), @@ -222,19 +236,42 @@ mod tests { } #[test] - fn copilot_convo_injects_copilot_dir() { + fn copilot_resolver_injects_copilot_dir() { let config = Config { home: Some(PathBuf::from("/home/jailed")), copilot_home: Some(PathBuf::from("/copilot/root")), ..Config::default() }; - let manager = copilot_convo(&config); + let resolver = copilot_resolver(&config).unwrap(); + assert_eq!(resolver.copilot_dir(), PathBuf::from("/copilot/root")); + } + + #[test] + fn copilot_resolver_roots_at_config_home() { + let resolver = copilot_resolver(&config_with_home()).unwrap(); assert_eq!( - manager.resolver().copilot_dir().unwrap(), - PathBuf::from("/copilot/root") + resolver.session_state_dir(), + PathBuf::from("/home/jailed/.copilot/session-state") ); } + #[test] + fn copilot_resolver_is_none_without_a_home() { + assert!(copilot_resolver(&Config::default()).is_none()); + let err = require_copilot_resolver(&Config::default()).unwrap_err(); + assert!(err.to_string().contains("home directory")); + } + + #[test] + fn copilot_strict_follows_presence_of_the_variable() { + assert!(!copilot_strict(&Config::default())); + let config = Config { + copilot_events_strict: Some(String::new()), + ..Config::default() + }; + assert!(copilot_strict(&config)); + } + #[test] fn opencode_convo_injects_data_dir() { let config = Config { diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index d572b84a..7a3baef5 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -975,7 +975,7 @@ mod tests { let user = r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"hi"}}"#; std::fs::write(dir.join("events.jsonl"), format!("{start}\n{user}\n")).unwrap(); - let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + let resolver = toolpath_copilot::PathResolver::new(home).with_copilot_dir(&copilot_dir); HarnessBundle { copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), ..Default::default() @@ -1166,7 +1166,7 @@ mod tests { ), ) .unwrap(); - let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir); + let resolver = toolpath_copilot::PathResolver::new(home).with_copilot_dir(&copilot_dir); let bundle = HarnessBundle { copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)), ..Default::default() diff --git a/crates/path-cli/src/sync/sources.rs b/crates/path-cli/src/sync/sources.rs index 0d4aded9..9a517c3d 100644 --- a/crates/path-cli/src/sync/sources.rs +++ b/crates/path-cli/src/sync/sources.rs @@ -489,7 +489,7 @@ impl ArtifactSource for CopilotSource<'_> { self.0.resolver().session_state_dir(), self.0.resolver().legacy_session_state_dir(), ]; - for dir in dirs.into_iter().flatten() { + for dir in dirs { let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; diff --git a/crates/toolpath-copilot/Cargo.toml b/crates/toolpath-copilot/Cargo.toml index cd58626d..53530497 100644 --- a/crates/toolpath-copilot/Cargo.toml +++ b/crates/toolpath-copilot/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-copilot" -version = "0.1.0" +version = "0.2.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-copilot/README.md b/crates/toolpath-copilot/README.md index cd872cbd..61f2d897 100644 --- a/crates/toolpath-copilot/README.md +++ b/crates/toolpath-copilot/README.md @@ -20,8 +20,9 @@ as the npm package [`@github/copilot`](https://www.npmjs.com/package/@github/cop ## What it reads -Sessions live under `~/.copilot/session-state//` (override the root -with `COPILOT_HOME`): +Sessions live under `~/.copilot/session-state//` (the caller +supplies the home directory; `PathResolver::with_copilot_dir` replaces the +whole root): - `events.jsonl` — the append-only event stream this crate parses into a conversation. @@ -37,7 +38,7 @@ It also tolerates the legacy `history-session-state/` location. ```rust,no_run use toolpath_copilot::{CopilotConvo, derive}; -let convo = CopilotConvo::new(); +let convo = CopilotConvo::new("/Users/alex"); // List sessions (newest first). for meta in convo.list_sessions()? { diff --git a/crates/toolpath-copilot/src/error.rs b/crates/toolpath-copilot/src/error.rs index bc89402a..21ea07c1 100644 --- a/crates/toolpath-copilot/src/error.rs +++ b/crates/toolpath-copilot/src/error.rs @@ -11,9 +11,6 @@ pub enum ConvoError { #[error("JSON parsing error: {0}")] Json(#[from] serde_json::Error), - #[error("Home directory not found")] - NoHomeDirectory, - #[error("Copilot directory not found at path: {0}")] CopilotDirectoryNotFound(PathBuf), diff --git a/crates/toolpath-copilot/src/io.rs b/crates/toolpath-copilot/src/io.rs index bc149f7b..ae86db11 100644 --- a/crates/toolpath-copilot/src/io.rs +++ b/crates/toolpath-copilot/src/io.rs @@ -6,20 +6,33 @@ use crate::reader::EventReader; use crate::types::{Session, SessionMetadata}; use std::path::PathBuf; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct ConvoIO { resolver: PathResolver, + strict: bool, } impl ConvoIO { - pub fn new() -> Self { + pub fn new>(home: P) -> Self { + Self::with_resolver(PathResolver::new(home)) + } + + pub fn with_resolver(resolver: PathResolver) -> Self { Self { - resolver: PathResolver::new(), + resolver, + strict: false, } } - pub fn with_resolver(resolver: PathResolver) -> Self { - Self { resolver } + /// Strict mode makes an unparseable events line an error instead of + /// a warning. + pub fn with_strict(mut self, strict: bool) -> Self { + self.strict = strict; + self + } + + pub fn strict(&self) -> bool { + self.strict } pub fn resolver(&self) -> &PathResolver { @@ -30,7 +43,7 @@ impl ConvoIO { self.resolver.exists() } - pub fn copilot_dir_path(&self) -> Result { + pub fn copilot_dir_path(&self) -> PathBuf { self.resolver.copilot_dir() } @@ -56,18 +69,18 @@ impl ConvoIO { /// Read one session by id (exact or unique prefix). pub fn read_session(&self, session_id: &str) -> Result { let dir = self.resolver.find_session_dir(session_id)?; - EventReader::read_session_dir(&dir) + EventReader::read_session_dir_with(&dir, self.strict) } /// Read one session by its directory path. pub fn read_session_dir>(&self, dir: P) -> Result { - EventReader::read_session_dir(dir) + EventReader::read_session_dir_with(dir, self.strict) } /// Cheap per-session metadata. Copilot session files have no compact /// header, so this walks the file (sessions are small). pub fn read_metadata>(&self, dir: P) -> Result { - let session = EventReader::read_session_dir(dir)?; + let session = EventReader::read_session_dir_with(dir, self.strict)?; Ok(SessionMetadata { id: session.id.clone(), dir_path: session.dir_path.clone(), @@ -102,7 +115,7 @@ mod tests { ] .join("\n"); fs::write(dir.join("events.jsonl"), body).unwrap(); - let resolver = PathResolver::new().with_copilot_dir(&copilot); + let resolver = PathResolver::new(temp.path()).with_copilot_dir(&copilot); (temp, ConvoIO::with_resolver(resolver)) } @@ -131,4 +144,20 @@ mod tests { assert!(io.session_exists("sess-abc")); assert!(!io.session_exists("nope")); } + + #[test] + fn strict_reads_reject_a_malformed_line() { + let (t, io) = setup(); + let dir = t.path().join(".copilot/session-state/sess-bad"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("events.jsonl"), "{bad}\n").unwrap(); + + assert!(io.read_session("sess-bad").is_ok()); + assert!( + io.clone() + .with_strict(true) + .read_session("sess-bad") + .is_err() + ); + } } diff --git a/crates/toolpath-copilot/src/paths.rs b/crates/toolpath-copilot/src/paths.rs index 28edab85..67bf6db1 100644 --- a/crates/toolpath-copilot/src/paths.rs +++ b/crates/toolpath-copilot/src/paths.rs @@ -1,10 +1,11 @@ //! Filesystem layout for GitHub Copilot CLI state. //! //! Sessions live at `~/.copilot/session-state//events.jsonl` -//! (see `docs/agents/formats/copilot-cli/directory-layout.md`). The root is -//! overridable with the `COPILOT_HOME` environment variable. Older sessions -//! may sit under the legacy `history-session-state/` directory; we glance at -//! it as a secondary location. +//! (see `docs/agents/formats/copilot-cli/directory-layout.md`). The caller +//! supplies the home directory, and [`PathResolver::with_copilot_dir`] +//! replaces the whole root. Older sessions may sit under the legacy +//! `history-session-state/` directory; we glance at it as a secondary +//! location. use crate::error::{ConvoError, Result}; use std::fs; @@ -20,62 +21,50 @@ const SESSION_STORE_DB: &str = "session-store.db"; /// Builder-style resolver over the `~/.copilot/` filesystem. #[derive(Debug, Clone)] pub struct PathResolver { - home_dir: Option, + home_dir: PathBuf, copilot_dir: Option, } -impl Default for PathResolver { - fn default() -> Self { - Self::new() - } -} - impl PathResolver { - pub fn new() -> Self { + pub fn new>(home: P) -> Self { Self { - home_dir: dirs::home_dir(), - // `COPILOT_HOME` replaces the entire `~/.copilot` root. - copilot_dir: std::env::var_os("COPILOT_HOME").map(PathBuf::from), + home_dir: home.into(), + copilot_dir: None, } } - pub fn with_home>(mut self, home: P) -> Self { - self.home_dir = Some(home.into()); - self - } - - /// Override the copilot directory directly (defaults to `~/.copilot`, - /// or `$COPILOT_HOME` when set). + /// Override the copilot directory directly (defaults to + /// `~/.copilot`). pub fn with_copilot_dir>(mut self, copilot_dir: P) -> Self { self.copilot_dir = Some(copilot_dir.into()); self } - pub fn home_dir(&self) -> Result<&Path> { - self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory) + pub fn home_dir(&self) -> &Path { + &self.home_dir } - pub fn copilot_dir(&self) -> Result { - if let Some(d) = &self.copilot_dir { - return Ok(d.clone()); + pub fn copilot_dir(&self) -> PathBuf { + match &self.copilot_dir { + Some(d) => d.clone(), + None => self.home_dir.join(COPILOT_SUBDIR), } - Ok(self.home_dir()?.join(COPILOT_SUBDIR)) } - pub fn session_state_dir(&self) -> Result { - Ok(self.copilot_dir()?.join(SESSION_STATE_SUBDIR)) + pub fn session_state_dir(&self) -> PathBuf { + self.copilot_dir().join(SESSION_STATE_SUBDIR) } - pub fn legacy_session_state_dir(&self) -> Result { - Ok(self.copilot_dir()?.join(LEGACY_SESSION_STATE_SUBDIR)) + pub fn legacy_session_state_dir(&self) -> PathBuf { + self.copilot_dir().join(LEGACY_SESSION_STATE_SUBDIR) } - pub fn session_store_db(&self) -> Result { - Ok(self.copilot_dir()?.join(SESSION_STORE_DB)) + pub fn session_store_db(&self) -> PathBuf { + self.copilot_dir().join(SESSION_STORE_DB) } pub fn exists(&self) -> bool { - self.copilot_dir().map(|p| p.exists()).unwrap_or(false) + self.copilot_dir().exists() } /// `events.jsonl` path for a resolved session id. @@ -92,7 +81,7 @@ impl PathResolver { /// `events.jsonl`, newest first by `events.jsonl` mtime. pub fn list_session_dirs(&self) -> Result> { let mut dirs = Vec::new(); - for root in [self.session_state_dir()?, self.legacy_session_state_dir()?] { + for root in [self.session_state_dir(), self.legacy_session_state_dir()] { collect_session_dirs(&root, &mut dirs); } dirs.sort_by_key(|p| { @@ -160,17 +149,6 @@ fn collect_session_dirs(root: &Path, out: &mut Vec) { } } -mod dirs { - use std::env; - use std::path::PathBuf; - - pub fn home_dir() -> Option { - env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .map(PathBuf::from) - } -} - #[cfg(test)] mod tests { use super::*; @@ -187,31 +165,27 @@ mod tests { let temp = TempDir::new().unwrap(); let copilot = temp.path().join(".copilot"); fs::create_dir_all(&copilot).unwrap(); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_copilot_dir(&copilot); + let resolver = PathResolver::new(temp.path()).with_copilot_dir(&copilot); (temp, resolver) } #[test] fn copilot_dir_defaults_to_home() { let temp = TempDir::new().unwrap(); - // Avoid COPILOT_HOME leaking in from the environment. - let r = PathResolver { - home_dir: Some(temp.path().to_path_buf()), - copilot_dir: None, - }; - assert_eq!(r.copilot_dir().unwrap(), temp.path().join(".copilot")); + let r = PathResolver::new(temp.path()); + assert_eq!(r.copilot_dir(), temp.path().join(".copilot")); + } + + #[test] + fn copilot_dir_override_wins_over_home() { + let r = PathResolver::new("/home/alex").with_copilot_dir("/copilot/root"); + assert_eq!(r.copilot_dir(), PathBuf::from("/copilot/root")); } #[test] fn session_state_dir_under_copilot_dir() { let (_t, r) = setup(); - assert!( - r.session_state_dir() - .unwrap() - .ends_with(".copilot/session-state") - ); + assert!(r.session_state_dir().ends_with(".copilot/session-state")); } #[test] diff --git a/crates/toolpath-copilot/src/provider.rs b/crates/toolpath-copilot/src/provider.rs index 430ef668..3913c999 100644 --- a/crates/toolpath-copilot/src/provider.rs +++ b/crates/toolpath-copilot/src/provider.rs @@ -654,14 +654,16 @@ fn str_arg(args: &Value, keys: &[&str]) -> Option { // ── Manager facade ─────────────────────────────────────────────────── /// Reads Copilot CLI sessions and converts them to [`ConversationView`]s. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct CopilotConvo { io: ConvoIO, } impl CopilotConvo { - pub fn new() -> Self { - Self { io: ConvoIO::new() } + pub fn new>(home: P) -> Self { + Self { + io: ConvoIO::new(home), + } } pub fn with_resolver(resolver: PathResolver) -> Self { @@ -670,6 +672,13 @@ impl CopilotConvo { } } + /// Strict mode makes an unparseable events line an error instead of + /// a warning. + pub fn with_strict(mut self, strict: bool) -> Self { + self.io = self.io.with_strict(strict); + self + } + pub fn io(&self) -> &ConvoIO { &self.io } diff --git a/crates/toolpath-copilot/src/reader.rs b/crates/toolpath-copilot/src/reader.rs index fc1f824d..82e801d6 100644 --- a/crates/toolpath-copilot/src/reader.rs +++ b/crates/toolpath-copilot/src/reader.rs @@ -1,8 +1,8 @@ //! Read a Copilot CLI session directory into a [`Session`]. //! //! The reader is tolerant: a malformed `events.jsonl` line is logged to -//! stderr and skipped (a crash can leave the final line truncated), unless -//! `COPILOT_EVENTS_STRICT` is set, in which case the first bad line errors. +//! stderr and skipped (a crash can leave the final line truncated). Strict +//! mode turns the first malformed line into an error. use crate::error::{ConvoError, Result}; use crate::types::{EventLine, Session, Workspace, parse_workspace}; @@ -15,9 +15,15 @@ const WORKSPACE_FILE: &str = "workspace.yaml"; pub struct EventReader; impl EventReader { - /// Read a `session-state//` directory. The session id is the - /// directory name. + /// Read a `session-state//` directory, skipping malformed lines. + /// The session id is the directory name. pub fn read_session_dir>(dir: P) -> Result { + Self::read_session_dir_with(dir, false) + } + + /// [`Self::read_session_dir`] with the strict flag supplied by the + /// caller. Strict mode returns the first malformed line as an error. + pub fn read_session_dir_with>(dir: P, strict: bool) -> Result { let dir = dir.as_ref(); let id = dir .file_name() @@ -25,7 +31,7 @@ impl EventReader { .ok_or_else(|| ConvoError::InvalidFormat(dir.to_path_buf()))? .to_string(); let events_path = dir.join(EVENTS_FILE); - let lines = Self::read_lines(&events_path)?; + let lines = Self::read_lines_with(&events_path, strict)?; let workspace = Self::read_workspace(dir); Ok(Session { id, @@ -44,16 +50,16 @@ impl EventReader { if ws.is_empty() { None } else { Some(ws) } } - /// Parse the JSONL lines of an `events.jsonl` file. Malformed-line - /// tolerance is controlled by `COPILOT_EVENTS_STRICT`. + /// Parse the JSONL lines of an `events.jsonl` file, skipping + /// malformed lines. pub fn read_lines>(path: P) -> Result> { - let strict = std::env::var_os("COPILOT_EVENTS_STRICT").is_some(); - Self::read_lines_impl(path.as_ref(), strict) + Self::read_lines_with(path, false) } - /// Parse the JSONL lines with `strict` passed explicitly (env-independent, - /// so tests don't race on a process-global var). - fn read_lines_impl(path: &Path, strict: bool) -> Result> { + /// [`Self::read_lines`] with the strict flag supplied by the caller. + /// Strict mode returns the first malformed line as an error. + pub fn read_lines_with>(path: P, strict: bool) -> Result> { + let path = path.as_ref(); let file = std::fs::File::open(path)?; let reader = BufReader::new(file); let mut lines = Vec::new(); @@ -143,15 +149,9 @@ mod tests { fn strict_mode_errors_on_malformed() { let body = "{bad}\n"; let (_t, dir) = session_dir("sess-2", body); - // Exercise strict mode directly (no process-global env mutation, which - // would race the concurrent non-strict test). - let res = EventReader::read_lines_impl(&dir.join("events.jsonl"), true); - assert!(res.is_err()); - // Non-strict tolerates the same file. - assert!( - EventReader::read_lines_impl(&dir.join("events.jsonl"), false) - .unwrap() - .is_empty() - ); + let events = dir.join("events.jsonl"); + assert!(EventReader::read_lines_with(&events, true).is_err()); + assert!(EventReader::read_lines(&events).unwrap().is_empty()); + assert!(EventReader::read_session_dir_with(&dir, true).is_err()); } } diff --git a/crates/toolpath-copilot/tests/roundtrip.rs b/crates/toolpath-copilot/tests/roundtrip.rs index d0e34343..f004d48f 100644 --- a/crates/toolpath-copilot/tests/roundtrip.rs +++ b/crates/toolpath-copilot/tests/roundtrip.rs @@ -25,7 +25,7 @@ fn setup() -> (TempDir, CopilotConvo, String) { "/tests/fixtures/sample-session.jsonl" ); fs::copy(fixture, dir.join("events.jsonl")).unwrap(); - let resolver = PathResolver::new().with_copilot_dir(&copilot); + let resolver = PathResolver::new(temp.path()).with_copilot_dir(&copilot); (temp, CopilotConvo::with_resolver(resolver), id.to_string()) } diff --git a/site/_data/crates.json b/site/_data/crates.json index 3fc3f754..085190e4 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -57,7 +57,7 @@ }, { "name": "toolpath-copilot", - "version": "0.1.0", + "version": "0.2.0", "description": "Derive from GitHub Copilot CLI session logs (preview)", "docs": "https://docs.rs/toolpath-copilot", "crate": "https://crates.io/crates/toolpath-copilot",