cont/tests/cli.rs

203 lines
6.9 KiB
Rust
Raw Normal View History

2026-09-15 13:13:22 +00:00
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::<String>(),
)
.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<Value> {
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());
}