diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ef6d4b..8fe386b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,35 @@ 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-codex`: the caller supplies the home directory — 2026-08-13 + +- **`toolpath-codex`** (0.7.0): breaking. `PathResolver::new(home)` + takes the home directory as a required argument. The crate reads no + environment variable; it keeps the layout knowledge (`/.codex`) + and the caller owns "what is home". `CodexConvo::new(home)` and + `ConvoIO::new(home)` take the same argument. + + Removed: the `Default` impls on `PathResolver`, `ConvoIO`, and + `CodexConvo`; `PathResolver::with_home`; the `NoHomeDirectory` and + `CodexDirectoryNotFound` error variants. `with_codex_dir` stays as + the full override. + + The home directory is always present, so `home_dir()`, `codex_dir()`, + `sessions_root()`, `history_file()`, `log_file()`, and + `ConvoIO::codex_dir_path()` return a path instead of a `Result`. + + Strict rollout parsing is a parameter. `CodexConvo::with_strict(bool)` + and `ConvoIO::with_strict(bool)` set it, + `RolloutReader::read_session_with(path, strict)` takes it directly, + and `RolloutReader::read_session(path)` stays lenient. The crate reads + no environment variable for it. +- **`path-cli`** (unreleased): `providers::codex_resolver` returns + `Option`. `None` means the configuration carries no home + directory, so Codex is out of reach: the harness bundle omits it, and + a command that targets Codex reports "cannot determine the home + directory". `Config` reads `$CODEX_ROLLOUT_STRICT` and passes the flag + to every `CodexConvo` it builds, so the variable keeps its behavior + for CLI users. ## `toolpath-gemini`: the caller supplies the home directory — 2026-08-13 diff --git a/Cargo.lock b/Cargo.lock index b97f08a6..2b485093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4243,7 +4243,7 @@ dependencies = [ [[package]] name = "toolpath-codex" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d09167e2..e5f4487c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ 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.2", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } -toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } +toolpath-codex = { version = "0.7.0", 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" } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 57ea641a..2de6fc7d 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -1221,10 +1221,8 @@ fn build_codex_session(config: &Config, input: &str, cwd: &str) -> Result Result<()> { let session_ts = codex_session_timestamp(session)?; - let resolver = providers::codex_resolver(config); - let sessions_root = resolver - .sessions_root() - .map_err(|e| anyhow::anyhow!("Cannot resolve Codex sessions dir: {}", e))?; + let resolver = providers::require_codex_resolver(config)?; + let sessions_root = resolver.sessions_root(); // sessions/YYYY/MM/DD/ let date_dir = sessions_root @@ -1240,9 +1238,7 @@ fn write_into_codex_project(session: &toolpath_codex::Session, config: &Config) // `codex resume` reads from state_5.sqlite, not the filesystem; // without a thread row the rollout file is invisible. - let codex_dir = resolver - .codex_dir() - .map_err(|e| anyhow::anyhow!("Cannot resolve ~/.codex dir: {}", e))?; + let codex_dir = resolver.codex_dir(); let registration = register_codex_thread(&codex_dir, session, &out_path, &session_ts); eprintln!( @@ -2879,13 +2875,8 @@ mod tests { ) .expect("export codex --project"); - let resolver = PathResolver::new().with_home(&fake_home); - let dated_dir = resolver - .sessions_root() - .unwrap() - .join("2026") - .join("05") - .join("15"); + let resolver = PathResolver::new(&fake_home); + let dated_dir = resolver.sessions_root().join("2026").join("05").join("15"); assert!( dated_dir.exists(), "expected dated sessions dir at {}", diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index ffe4bd5e..46ff9be8 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -824,7 +824,9 @@ fn pick_gemini_global( } fn derive_codex(session: Option, all: bool, config: &Config) -> Result> { - let manager = toolpath_codex::CodexConvo::with_resolver(providers::codex_resolver(config)); + let manager = + toolpath_codex::CodexConvo::with_resolver(providers::require_codex_resolver(config)?) + .with_strict(providers::codex_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 321e1075..d8e668ee 100644 --- a/crates/path-cli/src/cmd_list.rs +++ b/crates/path-cli/src/cmd_list.rs @@ -620,7 +620,9 @@ fn emit_gemini_tsv(m: &toolpath_gemini::ConversationMetadata) { // ── Codex ─────────────────────────────────────────────────────────────────── fn run_codex(fmt: ListFormat, config: &Config) -> Result<()> { - let manager = toolpath_codex::CodexConvo::with_resolver(providers::codex_resolver(config)); + let manager = + toolpath_codex::CodexConvo::with_resolver(providers::require_codex_resolver(config)?) + .with_strict(providers::codex_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 7b85c6c3..623c23fc 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -699,12 +699,10 @@ fn harness_status_codex(bundle: &HarnessBundle, home: Option<&std::path::Path>) let Some(mgr) = &bundle.codex else { return HarnessStatus::unresolved(); }; - match mgr.resolver().sessions_root() { - Ok(p) => HarnessStatus { - path: crate::config::home_relative(&p, home), - exists: p.exists(), - }, - Err(_) => HarnessStatus::unresolved(), + let p = mgr.resolver().sessions_root(); + HarnessStatus { + path: crate::config::home_relative(&p, home), + exists: p.exists(), } } @@ -1114,7 +1112,7 @@ mod tests { fn codex_only_bundle(home: &Path) -> HarnessBundle { let codex_dir = home.join(".codex"); std::fs::create_dir_all(&codex_dir).unwrap(); - let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir); + let resolver = toolpath_codex::PathResolver::new(home).with_codex_dir(&codex_dir); HarnessBundle { codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)), ..Default::default() diff --git a/crates/path-cli/src/cmd_show.rs b/crates/path-cli/src/cmd_show.rs index ca27a71e..7b089a7d 100644 --- a/crates/path-cli/src/cmd_show.rs +++ b/crates/path-cli/src/cmd_show.rs @@ -138,8 +138,10 @@ fn derive_one(source: ShowSource, config: &Config) -> Result session, project: _, } => { - let manager = - toolpath_codex::CodexConvo::with_resolver(providers::codex_resolver(config)); + let manager = toolpath_codex::CodexConvo::with_resolver( + providers::require_codex_resolver(config)?, + ) + .with_strict(providers::codex_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 50999ee3..aeb1bcbf 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -46,6 +46,7 @@ pub(crate) const DOCUMENTS_DIR_NAME: &str = "documents"; /// field name matches its variable. const CONFIG_ENV_VARS: &[&str] = &[ "APPDATA", + "CODEX_ROLLOUT_STRICT", "COPILOT_HOME", "HOME", PATHBASE_URL_ENV, @@ -68,6 +69,10 @@ const CONFIG_ENV_VARS: &[&str] = &[ pub struct Config { /// `$APPDATA`: Windows harness data root. pub(crate) appdata: Option, + /// `$CODEX_ROLLOUT_STRICT`: the Codex reader errors on an + /// unparseable rollout line. Presence is the signal; the value is + /// not read. + pub(crate) codex_rollout_strict: Option, /// `$COPILOT_HOME`: Copilot CLI session root override. pub(crate) copilot_home: Option, /// `$HOME`: config-root fallback and the harness resolvers' root. @@ -179,6 +184,7 @@ mod tests { figment::Jail::expect_with(|jail| { jail.set_env(CONFIG_DIR_ENV, "/tmp/cfg-root"); jail.set_env("HOME", "/home/jailed"); + jail.set_env("CODEX_ROLLOUT_STRICT", "1"); jail.set_env("XDG_DATA_HOME", "/home/jailed/.local/share"); jail.set_env("COPILOT_HOME", "/home/jailed/.copilot"); jail.set_env("APPDATA", "/home/jailed/appdata"); @@ -190,6 +196,7 @@ mod tests { config, Config { appdata: Some(PathBuf::from("/home/jailed/appdata")), + codex_rollout_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 5fe259ef..6286e9b9 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -159,7 +159,8 @@ pub(crate) fn derive_gemini_session_with( /// Derive a single Codex session given an explicit session id. pub(crate) fn derive_codex_session(config: &Config, session: &str) -> Result { derive_codex_session_with( - &toolpath_codex::CodexConvo::with_resolver(providers::codex_resolver(config)), + &toolpath_codex::CodexConvo::with_resolver(providers::require_codex_resolver(config)?) + .with_strict(providers::codex_strict(config)), session, ) } diff --git a/crates/path-cli/src/harness.rs b/crates/path-cli/src/harness.rs index 34bbfafd..9d9e1eed 100644 --- a/crates/path-cli/src/harness.rs +++ b/crates/path-cli/src/harness.rs @@ -111,8 +111,6 @@ pub(crate) fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool { 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 { diff --git a/crates/path-cli/src/providers.rs b/crates/path-cli/src/providers.rs index 8b94b1f9..3e53fb70 100644 --- a/crates/path-cli/src/providers.rs +++ b/crates/path-cli/src/providers.rs @@ -43,12 +43,19 @@ pub(crate) fn require_gemini_resolver(config: &Config) -> Result toolpath_codex::PathResolver { - let mut resolver = toolpath_codex::PathResolver::new(); - if let Some(home) = config.home_dir() { - resolver = resolver.with_home(home); - } - resolver +pub(crate) fn codex_resolver(config: &Config) -> Option { + config.home_dir().map(toolpath_codex::PathResolver::new) +} + +/// [`codex_resolver`] for a command that targets Codex. +pub(crate) fn require_codex_resolver(config: &Config) -> Result { + codex_resolver(config).ok_or_else(|| missing_home("Codex")) +} + +/// The Codex reader's strict flag. `$CODEX_ROLLOUT_STRICT` is strict +/// when set, whatever its value. +pub(crate) fn codex_strict(config: &Config) -> bool { + config.codex_rollout_strict.is_some() } pub(crate) fn copilot_resolver(config: &Config) -> toolpath_copilot::PathResolver { @@ -108,9 +115,9 @@ pub(crate) fn harness_bundle(config: &Config) -> HarnessBundle { claude_resolver(config), )), gemini: gemini_resolver(config).map(toolpath_gemini::GeminiConvo::with_resolver), - codex: Some(toolpath_codex::CodexConvo::with_resolver(codex_resolver( - config, - ))), + codex: codex_resolver(config).map(|r| { + toolpath_codex::CodexConvo::with_resolver(r).with_strict(codex_strict(config)) + }), copilot: Some(toolpath_copilot::CopilotConvo::with_resolver( copilot_resolver(config), )), @@ -165,13 +172,30 @@ mod tests { #[test] fn codex_resolver_roots_at_config_home() { - let resolver = codex_resolver(&config_with_home()); + let resolver = codex_resolver(&config_with_home()).unwrap(); assert_eq!( - resolver.sessions_root().unwrap(), + resolver.sessions_root(), PathBuf::from("/home/jailed/.codex/sessions") ); } + #[test] + fn codex_resolver_is_none_without_a_home() { + assert!(codex_resolver(&Config::default()).is_none()); + let err = require_codex_resolver(&Config::default()).unwrap_err(); + assert!(err.to_string().contains("home directory")); + } + + #[test] + fn codex_strict_follows_presence_of_the_variable() { + assert!(!codex_strict(&Config::default())); + let config = Config { + codex_rollout_strict: Some(String::new()), + ..Config::default() + }; + assert!(codex_strict(&config)); + } + #[test] fn copilot_resolver_injects_copilot_dir() { let config = Config { diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index c3a9d7d9..6f7853ab 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -889,7 +889,7 @@ mod tests { format!("{meta}\n{user}\n"), ) .unwrap(); - let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir); + let resolver = toolpath_codex::PathResolver::new(home).with_codex_dir(&codex_dir); HarnessBundle { codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)), ..Default::default() diff --git a/crates/toolpath-codex/Cargo.toml b/crates/toolpath-codex/Cargo.toml index 693636e3..1df0dc19 100644 --- a/crates/toolpath-codex/Cargo.toml +++ b/crates/toolpath-codex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-codex" -version = "0.6.1" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-codex/README.md b/crates/toolpath-codex/README.md index 5b3ddb5a..27f3c7bd 100644 --- a/crates/toolpath-codex/README.md +++ b/crates/toolpath-codex/README.md @@ -40,7 +40,7 @@ documents so every Codex-assisted change has a traceable origin. ```rust,no_run use toolpath_codex::{CodexConvo, derive::{DeriveConfig, derive_path}}; -let manager = CodexConvo::new(); +let manager = CodexConvo::new("/Users/alex"); let session_id = "019dabc6-8fef-7681-a054-b5bb75fcb97d"; let convo = manager.read_session(session_id)?; let path = derive_path(&convo, &DeriveConfig::default()); diff --git a/crates/toolpath-codex/src/derive.rs b/crates/toolpath-codex/src/derive.rs index e48cbb13..23d539be 100644 --- a/crates/toolpath-codex/src/derive.rs +++ b/crates/toolpath-codex/src/derive.rs @@ -65,7 +65,7 @@ mod tests { fs::create_dir_all(&day).unwrap(); let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d"; fs::write(day.join(format!("{}.jsonl", name)), body).unwrap(); - let resolver = crate::PathResolver::new().with_codex_dir(&codex); + let resolver = crate::PathResolver::new(temp.path()).with_codex_dir(&codex); (temp, CodexConvo::with_resolver(resolver), name.into()) } diff --git a/crates/toolpath-codex/src/error.rs b/crates/toolpath-codex/src/error.rs index 42d41050..c57156f3 100644 --- a/crates/toolpath-codex/src/error.rs +++ b/crates/toolpath-codex/src/error.rs @@ -11,12 +11,6 @@ pub enum ConvoError { #[error("JSON parsing error: {0}")] Json(#[from] serde_json::Error), - #[error("Home directory not found")] - NoHomeDirectory, - - #[error("Codex directory not found at path: {0}")] - CodexDirectoryNotFound(PathBuf), - #[error("Session not found: {0}")] SessionNotFound(String), diff --git a/crates/toolpath-codex/src/io.rs b/crates/toolpath-codex/src/io.rs index bc5340a4..3eb0883b 100644 --- a/crates/toolpath-codex/src/io.rs +++ b/crates/toolpath-codex/src/io.rs @@ -6,20 +6,33 @@ use crate::reader::RolloutReader; use crate::types::{RolloutItem, 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 rollout 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 codex_dir_path(&self) -> Result { + pub fn codex_dir_path(&self) -> PathBuf { self.resolver.codex_dir() } @@ -70,12 +83,12 @@ impl ConvoIO { /// Read one session by id or filename stem. pub fn read_session(&self, session_id: &str) -> Result { let path = self.resolver.find_rollout_file(session_id)?; - RolloutReader::read_session(&path) + RolloutReader::read_session_with(&path, self.strict) } /// Read one session by absolute path. pub fn read_session_path>(&self, path: P) -> Result { - RolloutReader::read_session(path) + RolloutReader::read_session_with(path, self.strict) } /// Cheap per-file metadata: parses the session_meta line + walks @@ -85,7 +98,7 @@ impl ConvoIO { // Full parse is simplest; rollout files are small (typical // session 200-300 KB). If that becomes a bottleneck we'd peek // the first line plus `stat` for mtime. - let session = RolloutReader::read_session(path)?; + let session = RolloutReader::read_session_with(path, self.strict)?; let meta_line = session.items().find_map(|item| match item { RolloutItem::SessionMeta(m) => Some(m), @@ -143,7 +156,7 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_codex_dir(&codex); + let resolver = PathResolver::new(temp.path()).with_codex_dir(&codex); (temp, ConvoIO::with_resolver(resolver)) } @@ -171,7 +184,7 @@ mod tests { #[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"); + let day = io.resolver().sessions_root().join("2026/04/21"); fs::create_dir_all(&day).unwrap(); fs::write( day.join("rollout-2026-04-21T09-00-00-bbb.jsonl"), @@ -221,7 +234,24 @@ mod tests { let temp = TempDir::new().unwrap(); let codex = temp.path().join(".codex"); fs::create_dir_all(&codex).unwrap(); - let io = ConvoIO::with_resolver(PathResolver::new().with_codex_dir(&codex)); + let io = ConvoIO::with_resolver(PathResolver::new(temp.path()).with_codex_dir(&codex)); assert!(io.list_sessions().unwrap().is_empty()); } + + #[test] + fn strict_reads_reject_an_unparseable_line() { + let (_t, io) = setup(); + let day = io.resolver().sessions_root().join("2026/04/21"); + fs::create_dir_all(&day).unwrap(); + let file = day.join("rollout-2026-04-21T09-00-00-bbb.jsonl"); + fs::write(&file, "not json").unwrap(); + + assert!(io.read_session_path(&file).is_ok()); + assert!( + io.clone() + .with_strict(true) + .read_session_path(&file) + .is_err() + ); + } } diff --git a/crates/toolpath-codex/src/paths.rs b/crates/toolpath-codex/src/paths.rs index c047a0af..62ba8b4c 100644 --- a/crates/toolpath-codex/src/paths.rs +++ b/crates/toolpath-codex/src/paths.rs @@ -17,66 +17,55 @@ const LOG_FILE: &str = "log/codex-tui.log"; /// Builder-style resolver over the `~/.codex/` filesystem. #[derive(Debug, Clone)] pub struct PathResolver { - home_dir: Option, + home_dir: PathBuf, codex_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(), + home_dir: home.into(), codex_dir: None, } } - pub fn with_home>(mut self, home: P) -> Self { - self.home_dir = Some(home.into()); - self - } - /// Override the codex directory directly (defaults to `~/.codex`). pub fn with_codex_dir>(mut self, codex_dir: P) -> Self { self.codex_dir = Some(codex_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 codex_dir(&self) -> Result { - if let Some(d) = &self.codex_dir { - return Ok(d.clone()); + pub fn codex_dir(&self) -> PathBuf { + match &self.codex_dir { + Some(d) => d.clone(), + None => self.home_dir.join(".codex"), } - Ok(self.home_dir()?.join(".codex")) } - pub fn sessions_root(&self) -> Result { - Ok(self.codex_dir()?.join(SESSIONS_SUBDIR)) + pub fn sessions_root(&self) -> PathBuf { + self.codex_dir().join(SESSIONS_SUBDIR) } - pub fn history_file(&self) -> Result { - Ok(self.codex_dir()?.join(HISTORY_FILE)) + pub fn history_file(&self) -> PathBuf { + self.codex_dir().join(HISTORY_FILE) } - pub fn log_file(&self) -> Result { - Ok(self.codex_dir()?.join(LOG_FILE)) + pub fn log_file(&self) -> PathBuf { + self.codex_dir().join(LOG_FILE) } pub fn exists(&self) -> bool { - self.codex_dir().map(|p| p.exists()).unwrap_or(false) + self.codex_dir().exists() } /// Enumerate every `rollout-*.jsonl` file under `sessions/`, newest /// first by file mtime. pub fn list_rollout_files(&self) -> Result> { - let root = self.sessions_root()?; + let root = self.sessions_root(); if !root.exists() { return Ok(Vec::new()); } @@ -155,7 +144,7 @@ impl PathResolver { return Ok(None); } let candidate = self - .sessions_root()? + .sessions_root() .join(y) .join(m) .join(d) @@ -237,17 +226,6 @@ fn walk_for_rollouts(dir: &Path, out: &mut Vec) -> Result<()> { Ok(()) } -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::*; @@ -257,29 +235,27 @@ mod tests { let temp = TempDir::new().unwrap(); let codex = temp.path().join(".codex"); fs::create_dir_all(&codex).unwrap(); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_codex_dir(&codex); + let resolver = PathResolver::new(temp.path()).with_codex_dir(&codex); (temp, resolver) } #[test] fn codex_dir_defaults_to_home() { let temp = TempDir::new().unwrap(); - let r = PathResolver::new().with_home(temp.path()); - assert_eq!(r.codex_dir().unwrap(), temp.path().join(".codex")); + let r = PathResolver::new(temp.path()); + assert_eq!(r.codex_dir(), temp.path().join(".codex")); } #[test] fn sessions_root_under_codex_dir() { let (_t, r) = setup(); - assert!(r.sessions_root().unwrap().ends_with(".codex/sessions")); + assert!(r.sessions_root().ends_with(".codex/sessions")); } #[test] fn list_rollouts_walks_date_tree() { let (_t, r) = setup(); - let day = r.sessions_root().unwrap().join("2026/04/20"); + let day = r.sessions_root().join("2026/04/20"); fs::create_dir_all(&day).unwrap(); fs::write(day.join("rollout-2026-04-20T10-00-00-aaa.jsonl"), "{}").unwrap(); fs::write(day.join("rollout-2026-04-20T11-00-00-bbb.jsonl"), "{}").unwrap(); @@ -307,7 +283,7 @@ mod tests { #[test] fn find_rollout_by_full_stem() { let (_t, r) = setup(); - let day = r.sessions_root().unwrap().join("2026/04/20"); + let day = r.sessions_root().join("2026/04/20"); fs::create_dir_all(&day).unwrap(); let stem = "rollout-2026-04-20T10-00-00-abc-xyz"; fs::write(day.join(format!("{}.jsonl", stem)), "{}").unwrap(); @@ -318,7 +294,7 @@ mod tests { #[test] fn find_rollout_by_uuid_suffix() { let (_t, r) = setup(); - let day = r.sessions_root().unwrap().join("2026/04/20"); + let day = r.sessions_root().join("2026/04/20"); fs::create_dir_all(&day).unwrap(); fs::write( day.join("rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d.jsonl"), @@ -337,7 +313,7 @@ mod tests { #[test] fn find_rollout_by_short_prefix() { let (_t, r) = setup(); - let day = r.sessions_root().unwrap().join("2026/04/20"); + let day = r.sessions_root().join("2026/04/20"); fs::create_dir_all(&day).unwrap(); fs::write( day.join("rollout-2026-04-20T10-00-00-019dabc6-unique.jsonl"), @@ -375,7 +351,7 @@ mod tests { #[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"); + let day = r.sessions_root().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(); @@ -393,7 +369,7 @@ mod tests { #[test] fn find_rollout_ambiguous_prefix_errors() { let (_t, r) = setup(); - let day = r.sessions_root().unwrap().join("2026/04/20"); + let day = r.sessions_root().join("2026/04/20"); fs::create_dir_all(&day).unwrap(); fs::write( day.join("rollout-2026-04-20T10-00-00-019dabc6-a.jsonl"), @@ -412,21 +388,15 @@ mod tests { #[test] fn history_and_log_file_paths() { let (t, r) = setup(); - assert_eq!( - r.history_file().unwrap(), - t.path().join(".codex/history.jsonl") - ); - assert_eq!( - r.log_file().unwrap(), - t.path().join(".codex/log/codex-tui.log") - ); + assert_eq!(r.history_file(), t.path().join(".codex/history.jsonl")); + assert_eq!(r.log_file(), t.path().join(".codex/log/codex-tui.log")); } #[test] fn exists_reflects_codex_dir() { let (_t, r) = setup(); assert!(r.exists()); - let missing = PathResolver::new().with_codex_dir("/never/exists"); + let missing = PathResolver::new("/never/exists"); assert!(!missing.exists()); } } diff --git a/crates/toolpath-codex/src/provider.rs b/crates/toolpath-codex/src/provider.rs index 5b915ce2..8393b527 100644 --- a/crates/toolpath-codex/src/provider.rs +++ b/crates/toolpath-codex/src/provider.rs @@ -47,14 +47,16 @@ use toolpath_convo::{ }; /// Provider for Codex sessions. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct CodexConvo { io: ConvoIO, } impl CodexConvo { - pub fn new() -> Self { - Self { io: ConvoIO::new() } + pub fn new>(home: P) -> Self { + Self { + io: ConvoIO::new(home), + } } pub fn with_resolver(resolver: crate::paths::PathResolver) -> Self { @@ -63,6 +65,13 @@ impl CodexConvo { } } + /// Strict mode makes an unparseable rollout 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 } @@ -1051,7 +1060,7 @@ mod tests { fs::create_dir_all(&day).unwrap(); let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d"; fs::write(day.join(format!("{}.jsonl", name)), body).unwrap(); - let resolver = crate::paths::PathResolver::new().with_codex_dir(&codex); + let resolver = crate::paths::PathResolver::new(temp.path()).with_codex_dir(&codex); (temp, CodexConvo::with_resolver(resolver), name.to_string()) } diff --git a/crates/toolpath-codex/src/reader.rs b/crates/toolpath-codex/src/reader.rs index 4632e164..7c7a8908 100644 --- a/crates/toolpath-codex/src/reader.rs +++ b/crates/toolpath-codex/src/reader.rs @@ -1,9 +1,9 @@ //! Parse Codex rollout JSONL files. //! -//! The writer is append-only but backgrounded — a crashed Codex -//! process may leave the final line mid-write. We skip unparseable -//! lines by default and surface them as warnings rather than failing -//! the whole read. +//! The writer is append-only but backgrounded, so a crashed Codex +//! process may leave the final line mid-write. The reader skips +//! unparseable lines and surfaces them as warnings. Strict mode turns +//! the first unparseable line into an error. use crate::error::{ConvoError, Result}; use crate::types::{RolloutLine, Session}; @@ -14,11 +14,19 @@ use std::path::{Path, PathBuf}; pub struct RolloutReader; impl RolloutReader { - /// Read every line of a rollout file into a [`Session`]. + /// Read every line of a rollout file into a [`Session`], skipping + /// unparseable lines. /// /// The session id is taken from the first line's `session_meta` /// payload if present; otherwise from the filename stem. pub fn read_session>(path: P) -> Result { + Self::read_session_with(path, false) + } + + /// [`Self::read_session`] with the strict flag supplied by the + /// caller. Strict mode returns the first unparseable line as an + /// error. + pub fn read_session_with>(path: P, strict: bool) -> Result { let path = path.as_ref(); if !path.exists() { return Err(ConvoError::SessionNotFound(path.display().to_string())); @@ -46,9 +54,7 @@ impl RolloutReader { match serde_json::from_str::(&raw) { Ok(line) => lines.push(line), Err(e) => { - // Tolerate a single truncated last line (common after crashes); - // warn about anything else. - if std::env::var("CODEX_ROLLOUT_STRICT").is_ok() { + if strict { return Err(ConvoError::Json(e)); } eprintln!( @@ -152,39 +158,22 @@ mod tests { assert!(matches!(err, ConvoError::SessionNotFound(_))); } - /// Serializes access to `CODEX_ROLLOUT_STRICT` across tests in this - /// module. Two tests probe `read_session` with opposing strictness - /// expectations; without serialization, cargo test's threaded - /// runner can observe the env var set by one test during another. - fn strict_env_lock() -> std::sync::MutexGuard<'static, ()> { - use std::sync::{Mutex, OnceLock}; - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|p| p.into_inner()) - } - #[test] fn read_session_handles_truncated_last_line() { - let _g = strict_env_lock(); - // Belt-and-braces: even under the lock, make sure the env var - // is clear before we observe lenient behavior. - unsafe { std::env::remove_var("CODEX_ROLLOUT_STRICT") }; - // Good first line, garbage second — reader skips and warns. - let body = sample_rollout() + "\n{\"timestamp\":\"broken"; // truncated + // Good first line, truncated second: the reader skips and warns. + let body = sample_rollout() + "\n{\"timestamp\":\"broken"; let f = write_fixture(&body); let s = RolloutReader::read_session(f.path()).unwrap(); assert_eq!(s.lines.len(), 4, "truncated line dropped, others kept"); + let s = RolloutReader::read_session_with(f.path(), false).unwrap(); + assert_eq!(s.lines.len(), 4); } #[test] - fn read_session_respects_strict_env() { - let _g = strict_env_lock(); + fn read_session_strict_errors_on_unparseable_line() { let body = sample_rollout() + "\n{\"timestamp\":\"broken"; let f = write_fixture(&body); - unsafe { std::env::set_var("CODEX_ROLLOUT_STRICT", "1") }; - let err = RolloutReader::read_session(f.path()).unwrap_err(); - unsafe { std::env::remove_var("CODEX_ROLLOUT_STRICT") }; + let err = RolloutReader::read_session_with(f.path(), true).unwrap_err(); assert!(matches!(err, ConvoError::Json(_))); } diff --git a/crates/toolpath-codex/tests/fixture_roundtrip.rs b/crates/toolpath-codex/tests/fixture_roundtrip.rs index 1a5cb501..a1e53ad2 100644 --- a/crates/toolpath-codex/tests/fixture_roundtrip.rs +++ b/crates/toolpath-codex/tests/fixture_roundtrip.rs @@ -295,7 +295,7 @@ fn list_sessions_via_convo() { let dst = day.join("rollout-2026-04-20T12-43-30-019dabc6-8fef-7681-a054-b5bb75fcb97d.jsonl"); std::fs::copy(fixture_path(), &dst).unwrap(); - let resolver = PathResolver::new().with_codex_dir(&codex); + let resolver = PathResolver::new(temp.path()).with_codex_dir(&codex); let mgr = CodexConvo::with_resolver(resolver); let sessions = mgr.list_sessions().unwrap(); assert_eq!(sessions.len(), 1); diff --git a/site/_data/crates.json b/site/_data/crates.json index 83f25cd8..715e093a 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -49,7 +49,7 @@ }, { "name": "toolpath-codex", - "version": "0.6.1", + "version": "0.7.0", "description": "Derive from Codex CLI rollout files", "docs": "https://docs.rs/toolpath-codex", "crate": "https://crates.io/crates/toolpath-codex",