use serde_json::{Value, json}; use std::{ fs, path::Path, process::{Command, Output}, }; struct Fixture { dir: tempfile::TempDir, } impl Fixture { fn new() -> Self { let dir = tempfile::tempdir().unwrap(); for path in [ "pi", "claude/projects/project", "codex/sessions", "work", "other", ] { fs::create_dir_all(dir.path().join(path)).unwrap(); } let f = Self { dir }; let root = f.dir.path(); f.jsonl("pi/pi.jsonl", &[ json!({"type":"session", "id":"pi-id", "cwd":root.join("work")}), json!({"type":"session_info", "name":"Database migration"}), json!({"type":"message", "message":{"role":"user","content":"add postgres indexes"}}), json!({"type":"message", "message":{"role":"assistant","content":"assistant-only-secret"}}), ]); f.jsonl("claude/projects/project/claude.jsonl", &[ json!({"type":"user", "sessionId":"claude-id", "cwd":root.join("other"), "message":{"role":"user","content":"consider migration rollback"}}), json!({"type":"ai-title", "aiTitle":"Different topic"}), ]); f.jsonl("codex/sessions/codex.jsonl", &[ json!({"type":"session_meta", "payload":{"id":"codex-id", "cwd":root.join("work"), "source":"cli"}}), json!({"type":"event_msg", "payload":{"type":"user_message", "message":"refactor rust"}}), ]); f } fn jsonl(&self, path: &str, rows: &[Value]) { fs::write( self.dir.path().join(path), rows.iter().map(|v| format!("{v}\n")).collect::(), ) .unwrap(); } fn run(&self, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_cont")) .env("HOME", self.dir.path()) .env("XDG_DATA_HOME", self.dir.path().join("data")) .env_remove("OPENCODE_DB") .env("PI_CODING_AGENT_SESSION_DIR", self.dir.path().join("pi")) .env("CLAUDE_CONFIG_DIR", self.dir.path().join("claude")) .env("CODEX_HOME", self.dir.path().join("codex")) .env("RESUME_CACHE_DIR", self.dir.path().join("cache")) .current_dir(self.dir.path().join("work")) .args(args) .output() .unwrap() } fn rows(&self, args: &[&str]) -> Vec { let out = self.run(args); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); serde_json::from_slice(&out.stdout).unwrap() } } #[test] fn cli_scope_agent_and_title_first_ranking() { let f = Fixture::new(); assert_eq!(f.rows(&["--json"]).len(), 2); assert_eq!(f.rows(&["--json", "--all"]).len(), 3); let rows = f.rows(&["--json", "--all", "migration"]); assert_eq!(rows.len(), 2); assert_eq!(rows[0]["agent"], "pi"); assert_eq!(rows[0]["match"], "title"); assert_eq!(rows[1]["agent"], "claude"); assert_eq!(rows[1]["match"], "message"); assert_eq!( f.rows(&["--json", "--all", "--agent", "codex"])[0]["id"], "codex-id" ); assert!( f.rows(&["--json", "--all", "assistant-only-secret"]) .is_empty() ); } #[test] fn omp_discovery_and_custom_opencode_database() { let f = Fixture::new(); let root = f.dir.path(); fs::create_dir_all(root.join(".omp/agent/sessions/project")).unwrap(); f.jsonl( ".omp/agent/sessions/project/session.jsonl", &[ json!({"type":"title","title":"OMP task"}), json!({"type":"session","id":"omp-id","cwd":root.join("work")}), json!({"type":"message","message":{"role":"user","content":"unicorn request"}}), ], ); let rows = f.rows(&["--json", "--agent", "omp", "unicorn"]); assert_eq!(rows.len(), 1); assert_eq!(rows[0]["title"], "OMP task"); assert_eq!(rows[0]["match"], "message"); assert!( rows[0]["command"] .as_str() .unwrap() .contains("omp '--resume'") ); let database = root.join("custom.db"); let conn = rusqlite::Connection::open(&database).unwrap(); conn.execute_batch("CREATE TABLE session (id TEXT, directory TEXT, title TEXT, time_updated INTEGER, parent_id TEXT, time_archived INTEGER); CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, data TEXT); CREATE TABLE part (id TEXT, message_id TEXT, session_id TEXT, time_created INTEGER, data TEXT);").unwrap(); conn.execute( "INSERT INTO session VALUES ('ses_test', ?1, 'OpenCode task', 1000, NULL, NULL)", [root.join("other").to_str().unwrap()], ) .unwrap(); let rows = f.rows(&[ "--json", "--all", "--agent", "opencode", "--opencode-db", database.to_str().unwrap(), ]); assert_eq!(rows.len(), 1); assert_eq!(rows[0]["id"], "ses_test"); assert!( rows[0]["command"] .as_str() .unwrap() .contains("OPENCODE_DB=") ); assert!( f.rows(&["--json", "--all", "--agent", "opencode"]) .is_empty() ); } #[test] fn additional_root_and_invalid_cache_are_safe() { let f = Fixture::new(); let extra = f.dir.path().join("extra"); fs::create_dir(&extra).unwrap(); fs::write( extra.join("extra.jsonl"), format!( "{}\n", json!({"type":"session","id":"extra","cwd":f.dir.path().join("work")}) ), ) .unwrap(); assert_eq!( f.rows(&["--json", "--all", "--pi-dir", extra.to_str().unwrap()]) .len(), 4 ); // Changing the configured roots must not leak old cached results into the final list. assert_eq!(f.rows(&["--json", "--all"]).len(), 3); fs::write(f.dir.path().join("cache/sessions-v1.json"), "bad cache").unwrap(); assert_eq!(f.rows(&["--json", "--all"]).len(), 3); } #[test] fn non_tty_help_and_no_cache() { let f = Fixture::new(); assert!(f.run(&["--help"]).status.success()); let out = f.run(&[]); assert!(!out.status.success()); assert!(String::from_utf8_lossy(&out.stderr).contains("needs a terminal")); assert_eq!(f.rows(&["--json", "--no-cache"]).len(), 2); assert!(!f.dir.path().join("cache").exists()); assert!(f.rows(&["--json", "--limit", "0"]).is_empty()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let cache = f.dir.path().join("cache/sessions-v1.json"); assert_eq!( fs::metadata(cache).unwrap().permissions().mode() & 0o777, 0o600 ); } } #[test] #[cfg(unix)] fn symlinked_working_directory_matches() { let f = Fixture::new(); let link = f.dir.path().join("alias"); std::os::unix::fs::symlink(f.dir.path().join("work"), &link).unwrap(); assert_eq!(f.rows(&["--json", "-C", link.to_str().unwrap()]).len(), 2); assert!(Path::new(f.rows(&["--json"])[0]["cwd"].as_str().unwrap()).is_absolute()); }