Files
Jeff Emanuel 77a3b8e379 fix(cli): route copied search coordinates through exact archive lookup (#493)
Finish the canonical follow-up fix on top of aa5d506 and 1f37b65:
- normalize pasted line_number/message_index fields to --message-index,
  preserve explicit legacy raw-line flags, and reject conflicting selectors;
- preserve complete path/source/conversation/index anchors in robot workflows,
  help, schema introspection and the capabilities golden contract;
- extend CLI regressions to 15 cases, including copied search fields, sparse
  indices, missing/corrupt archives, unchanged files, empty-conversation
  ambiguity and integer flag discovery; update affected existing CLI tests;
- run the actual regression target, raw-line compatibility, dispatch,
  structured errors and Clippy in permanent read-only CI. Retire temporary
  blob-publishing permissions; retain the manual regression entry point.

Keep 1f37b65's borrowed SQL parameters and strict read-only admission. Preserve
all concurrent semantic/indexer work, including d002bfa; no PR or force update.

Validation: exact-source preparation, rustfmt and git diff --check passed in
Actions run 35556869412; downloaded candidate blob hashes verified. Native
tests/Clippy are not yet qualified; permanent CI validates this commit.
2026-09-20 23:23:59 -04:00

4564 lines
153 KiB
Rust

//! CLI dispatch subprocess integration tests.
//!
//! This module covers CLI commands that were previously untested via subprocess
//! invocation. Tests invoke the real binary with representative flags, validate
//! output formats, exit codes, and JSON structure.
//!
//! Coverage targets: completions, man, health, doctor, context, timeline, expand,
//! export, export-html, sources subcommands, models subcommands.
use assert_cmd::Command;
use coding_agent_search::evidence_bundle::{
EvidenceBundleChunk, EvidenceBundleChunkRole, EvidenceBundleKind, EvidenceBundleManifest,
};
use coding_agent_search::franken_sync::compat::{ConnectionExt, RowExt};
use coding_agent_search::franken_sync::{Connection as FrankenConnection, params as fparams};
use coding_agent_search::model::types::{Agent, AgentKind, Conversation, Message, MessageRole};
use predicates::prelude::*;
use predicates::str::contains;
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tempfile::TempDir;
/// Create a base command with isolated test environment.
fn base_cmd(temp_home: &Path) -> Command {
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cass"));
cmd.env("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT", "1");
let data_dir = temp_home.join(".local/share/coding-agent-search");
// Isolate test environment
cmd.env("HOME", temp_home);
cmd.env("XDG_DATA_HOME", temp_home.join(".local/share"));
cmd.env("CASS_DATA_DIR", data_dir);
cmd.env("XDG_CONFIG_HOME", temp_home.join(".config"));
cmd.env("CODEX_HOME", temp_home.join(".codex"));
// Disable TTY detection
cmd.env("NO_COLOR", "1");
cmd
}
/// Create base command without HOME isolation (for simple tests), but with isolated XDG_DATA_HOME.
fn simple_cmd() -> Command {
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("cass"));
cmd.env("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT", "1");
cmd.env("NO_COLOR", "1");
// Create an isolated empty database with schema to avoid hitting the real user DB
let tmp = tempfile::TempDir::new().unwrap();
let db_dir = tmp.path().join("coding-agent-search");
std::fs::create_dir_all(&db_dir).unwrap();
let db_path = db_dir.join("agent_search.db");
// Initialize the schema
let fs = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
drop(fs);
cmd.env("XDG_DATA_HOME", tmp.path());
cmd.env("CASS_DATA_DIR", &db_dir);
// Leak the temp dir so it survives the command execution
std::mem::forget(tmp);
cmd
}
fn run_on_large_stack<T, F>(f: F) -> T
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
let handle = std::thread::Builder::new()
.name("cass-cli-dispatch-parse-test".to_string())
.stack_size(16 * 1024 * 1024)
.spawn(f)
.expect("spawn large-stack test thread");
match handle.join() {
Ok(value) => value,
Err(panic) => std::panic::resume_unwind(panic),
}
}
fn parse_cli_ok<const N: usize>(
args: [&'static str; N],
context: &'static str,
) -> coding_agent_search::Cli {
run_on_large_stack(move || {
<coding_agent_search::Cli as clap::Parser>::try_parse_from(args).expect(context)
})
}
fn parse_cli_err<const N: usize>(args: [&'static str; N], context: &'static str) -> clap::Error {
run_on_large_stack(move || {
match <coding_agent_search::Cli as clap::Parser>::try_parse_from(args) {
Ok(_) => panic!("{context}"),
Err(err) => err,
}
})
}
fn sample_agent(slug: &str, name: &str) -> Agent {
Agent {
id: None,
slug: slug.to_string(),
name: name.to_string(),
version: None,
kind: AgentKind::Cli,
}
}
fn sample_message(idx: i64, role: MessageRole, ts: i64, content: &str) -> Message {
Message {
id: None,
idx,
role,
author: None,
created_at: Some(ts),
content: content.to_string(),
extra_json: json!({}),
snippets: Vec::new(),
}
}
fn make_codex_session(root: &Path, content: &str, ts: u64) {
let sessions = root.join("sessions/2024/12/01");
fs::create_dir_all(&sessions).unwrap();
let file = sessions.join("rollout-test.jsonl");
let sample = format!(
r#"{{"type": "event_msg", "timestamp": {ts}, "payload": {{"type": "user_message", "message": "{content}"}}}}
{{"type": "response_item", "timestamp": {}, "payload": {{"role": "assistant", "content": "{content}_response"}}}}
"#,
ts + 1000
);
fs::write(file, sample).unwrap();
}
fn sample_conversation(
agent_slug: &str,
workspace: &Path,
source_path: &Path,
external_id: &str,
title: &str,
started_at: i64,
messages: Vec<Message>,
) -> Conversation {
Conversation {
id: None,
agent_slug: agent_slug.to_string(),
workspace: Some(workspace.to_path_buf()),
external_id: Some(external_id.to_string()),
title: Some(title.to_string()),
source_path: source_path.to_path_buf(),
started_at: Some(started_at),
ended_at: messages.last().and_then(|msg| msg.created_at),
approx_tokens: None,
metadata_json: json!({}),
messages,
source_id: "local".to_string(),
origin_host: None,
}
}
fn seed_analytics_workspace_fixture(temp_home: &TempDir) -> (PathBuf, PathBuf) {
let data_dir = temp_home.path().join(".local/share/coding-agent-search");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace_a = temp_home.path().join("workspace-a");
let workspace_b = temp_home.path().join("workspace-b");
fs::create_dir_all(&workspace_a).unwrap();
fs::create_dir_all(&workspace_b).unwrap();
let session_a = workspace_a.join("analytics-a.jsonl");
let session_b = workspace_b.join("analytics-b.jsonl");
fs::write(&session_a, "{}\n").unwrap();
fs::write(&session_b, "{}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_a_id = storage
.ensure_workspace(&workspace_a, Some("workspace-a"))
.unwrap();
let workspace_b_id = storage
.ensure_workspace(&workspace_b, Some("workspace-b"))
.unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
storage
.insert_conversation_tree(
codex_id,
Some(workspace_a_id),
&sample_conversation(
"codex",
&workspace_a,
&session_a,
"analytics-workspace-a",
"Workspace A Analytics Session",
now_ms,
vec![
sample_message(0, MessageRole::User, now_ms, "question-a"),
sample_message(1, MessageRole::Agent, now_ms + 1, "answer-a"),
],
),
)
.unwrap();
storage
.insert_conversation_tree(
codex_id,
Some(workspace_b_id),
&sample_conversation(
"codex",
&workspace_b,
&session_b,
"analytics-workspace-b",
"Workspace B Analytics Session",
now_ms + 10,
vec![sample_message(
0,
MessageRole::User,
now_ms + 10,
"question-b",
)],
),
)
.unwrap();
storage.rebuild_analytics().unwrap();
(workspace_a, workspace_b)
}
fn seed_analytics_models_workspace_fixture(temp_home: &TempDir) -> PathBuf {
let (workspace_a, _workspace_b) = seed_analytics_workspace_fixture(temp_home);
let db_path = temp_home
.path()
.join(".local/share/coding-agent-search/agent_search.db");
let conn = FrankenConnection::open(db_path.to_string_lossy().to_string()).unwrap();
let workspace_rows = conn
.query_map_collect(
"SELECT path, id FROM workspaces",
&[],
|row: &coding_agent_search::franken_sync::Row| {
Ok((row.get_typed::<String>(0)?, row.get_typed::<i64>(1)?))
},
)
.unwrap();
let workspace_a_id = workspace_rows
.into_iter()
.find(|(path, _)| path == &workspace_a.to_string_lossy())
.map(|(_, id)| id)
.expect("workspace-a id");
let message_rows = conn
.query_map_collect(
"SELECT m.id, m.conversation_id, c.workspace_id, c.agent_id, m.role, COALESCE(m.created_at, 0), LENGTH(m.content)
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
ORDER BY m.id",
&[],
|row: &coding_agent_search::franken_sync::Row| {
Ok((
row.get_typed::<i64>(0)?,
row.get_typed::<i64>(1)?,
row.get_typed::<Option<i64>>(2)?.expect("workspace id"),
row.get_typed::<i64>(3)?,
row.get_typed::<String>(4)?,
row.get_typed::<i64>(5)?,
row.get_typed::<i64>(6)?,
))
},
)
.unwrap();
let mut workspace_a_totals = vec![12_i64, 17_i64].into_iter();
for (message_id, conversation_id, workspace_id, agent_id, role, created_at, content_chars) in
message_rows
{
let (model_name, model_family, total_tokens) = if workspace_id == workspace_a_id {
(
Some("gpt-4o-mini".to_string()),
Some("gpt-4o".to_string()),
workspace_a_totals.next().expect("workspace-a token total"),
)
} else {
(
Some("claude-3-5-sonnet".to_string()),
Some("claude".to_string()),
11,
)
};
let usage_json = match role.as_str() {
"user" => json!({
"cass": {
"model": model_name,
"token_usage": {
"input_tokens": total_tokens,
"data_source": "api"
}
}
}),
_ => json!({
"cass": {
"model": model_name,
"token_usage": {
"output_tokens": total_tokens,
"data_source": "api"
}
}
}),
};
let day_id =
coding_agent_search::storage::sqlite::FrankenStorage::day_id_from_millis(created_at);
conn.execute_compat(
"INSERT OR REPLACE INTO token_usage (
message_id, conversation_id, agent_id, workspace_id, source_id, timestamp_ms, day_id,
model_name, model_family, total_tokens, role, content_chars, data_source
) VALUES (?1, ?2, ?3, ?4, 'local', ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'api')",
coding_agent_search::franken_sync::params![
message_id,
conversation_id,
agent_id,
workspace_id,
created_at,
day_id,
model_name,
model_family,
total_tokens,
role,
content_chars,
],
)
.unwrap();
conn.execute_compat(
"UPDATE messages SET extra_json = ?1 WHERE id = ?2",
coding_agent_search::franken_sync::params![usage_json.to_string(), message_id],
)
.unwrap();
}
let token_daily_rows = conn
.query_map_collect(
"SELECT tu.day_id,
a.slug,
tu.source_id,
COALESCE(tu.model_family, 'unknown'),
COUNT(*) AS api_call_count,
SUM(CASE WHEN tu.role = 'user' THEN 1 ELSE 0 END) AS user_message_count,
SUM(CASE WHEN tu.role IN ('assistant', 'agent') THEN 1 ELSE 0 END) AS assistant_message_count,
SUM(CASE WHEN tu.role = 'tool' THEN 1 ELSE 0 END) AS tool_message_count,
SUM(COALESCE(tu.input_tokens, 0)) AS total_input_tokens,
SUM(COALESCE(tu.output_tokens, 0)) AS total_output_tokens,
SUM(COALESCE(tu.cache_read_tokens, 0)) AS total_cache_read_tokens,
SUM(COALESCE(tu.cache_creation_tokens, 0)) AS total_cache_creation_tokens,
SUM(COALESCE(tu.thinking_tokens, 0)) AS total_thinking_tokens,
SUM(COALESCE(tu.total_tokens, 0)) AS grand_total_tokens,
SUM(COALESCE(tu.content_chars, 0)) AS total_content_chars,
SUM(COALESCE(tu.tool_call_count, 0)) AS total_tool_calls,
SUM(COALESCE(tu.estimated_cost_usd, 0.0)) AS estimated_cost_usd,
COUNT(DISTINCT tu.conversation_id) AS session_count,
MAX(tu.timestamp_ms) AS last_updated
FROM token_usage tu
JOIN agents a ON a.id = tu.agent_id
GROUP BY tu.day_id, a.slug, tu.source_id, COALESCE(tu.model_family, 'unknown')
ORDER BY tu.day_id, a.slug",
&[],
|row: &coding_agent_search::franken_sync::Row| {
Ok((
row.get_typed::<i64>(0)?,
row.get_typed::<String>(1)?,
row.get_typed::<String>(2)?,
row.get_typed::<String>(3)?,
row.get_typed::<i64>(4)?,
row.get_typed::<i64>(5)?,
row.get_typed::<i64>(6)?,
row.get_typed::<i64>(7)?,
row.get_typed::<i64>(8)?,
row.get_typed::<i64>(9)?,
row.get_typed::<i64>(10)?,
row.get_typed::<i64>(11)?,
row.get_typed::<i64>(12)?,
row.get_typed::<i64>(13)?,
row.get_typed::<i64>(14)?,
row.get_typed::<i64>(15)?,
row.get_typed::<f64>(16)?,
row.get_typed::<i64>(17)?,
row.get_typed::<i64>(18)?,
))
},
)
.unwrap();
for (
day_id,
agent_slug,
source_id,
model_family,
api_call_count,
user_message_count,
assistant_message_count,
tool_message_count,
total_input_tokens,
total_output_tokens,
total_cache_read_tokens,
total_cache_creation_tokens,
total_thinking_tokens,
grand_total_tokens,
total_content_chars,
total_tool_calls,
estimated_cost_usd,
session_count,
last_updated,
) in token_daily_rows
{
conn.execute_compat(
"INSERT OR REPLACE INTO token_daily_stats (
day_id, agent_slug, source_id, model_family,
api_call_count, user_message_count, assistant_message_count, tool_message_count,
total_input_tokens, total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens,
total_thinking_tokens, grand_total_tokens, total_content_chars, total_tool_calls,
estimated_cost_usd, session_count, last_updated
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
coding_agent_search::franken_sync::params![
day_id,
agent_slug,
source_id,
model_family,
api_call_count,
user_message_count,
assistant_message_count,
tool_message_count,
total_input_tokens,
total_output_tokens,
total_cache_read_tokens,
total_cache_creation_tokens,
total_thinking_tokens,
grand_total_tokens,
total_content_chars,
total_tool_calls,
estimated_cost_usd,
session_count,
last_updated,
],
)
.unwrap();
}
drop(conn);
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
storage.rebuild_analytics().unwrap();
workspace_a
}
fn seed_analytics_remote_source_tokens_fixture(temp_home: &TempDir) {
let (_workspace_a, workspace_b) = seed_analytics_workspace_fixture(temp_home);
let db_path = temp_home
.path()
.join(".local/share/coding-agent-search/agent_search.db");
let conn = FrankenConnection::open(db_path.to_string_lossy().to_string()).unwrap();
// The modern schema already carries origin_host; fsqlite 0.3.x correctly
// rejects the duplicate ALTER that 0.2.x silently accepted.
if let Err(err) = conn.execute("ALTER TABLE conversations ADD COLUMN origin_host TEXT") {
assert!(
err.to_string().contains("duplicate column"),
"unexpected origin_host ALTER failure: {err}"
);
}
let workspace_rows = conn
.query_map_collect(
"SELECT path, id FROM workspaces",
&[],
|row: &coding_agent_search::franken_sync::Row| {
Ok((row.get_typed::<String>(0)?, row.get_typed::<i64>(1)?))
},
)
.unwrap();
let workspace_b_id = workspace_rows
.into_iter()
.find(|(path, _)| path == &workspace_b.to_string_lossy())
.map(|(_, id)| id)
.expect("workspace-b id");
conn.execute_compat(
"UPDATE conversations SET source_id = ' ', origin_host = 'remote-ci' WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
conn.execute_compat(
"UPDATE message_metrics SET source_id = ' ' WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
conn.execute_compat(
"UPDATE usage_hourly SET source_id = ' ' WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
conn.execute_compat(
"UPDATE usage_daily SET source_id = ' ' WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
}
fn seed_analytics_remote_source_tools_fixture(temp_home: &TempDir) {
let (_workspace_a, workspace_b) = seed_analytics_workspace_fixture(temp_home);
let db_path = temp_home
.path()
.join(".local/share/coding-agent-search/agent_search.db");
let conn = FrankenConnection::open(db_path.to_string_lossy().to_string()).unwrap();
// The modern schema already carries origin_host; fsqlite 0.3.x correctly
// rejects the duplicate ALTER that 0.2.x silently accepted.
if let Err(err) = conn.execute("ALTER TABLE conversations ADD COLUMN origin_host TEXT") {
assert!(
err.to_string().contains("duplicate column"),
"unexpected origin_host ALTER failure: {err}"
);
}
let workspace_rows = conn
.query_map_collect(
"SELECT path, id FROM workspaces",
&[],
|row: &coding_agent_search::franken_sync::Row| {
Ok((row.get_typed::<String>(0)?, row.get_typed::<i64>(1)?))
},
)
.unwrap();
let workspace_b_id = workspace_rows
.into_iter()
.find(|(path, _)| path == &workspace_b.to_string_lossy())
.map(|(_, id)| id)
.expect("workspace-b id");
conn.execute_compat(
"UPDATE conversations SET source_id = ' ', origin_host = 'remote-ci' WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
conn.execute_compat(
"UPDATE message_metrics
SET source_id = ' ', tool_call_count = 7, content_tokens_est = 90,
api_input_tokens = 30, api_output_tokens = 70,
api_cache_read_tokens = 0, api_cache_creation_tokens = 0, api_thinking_tokens = 0
WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
conn.execute_compat(
"UPDATE usage_hourly
SET source_id = ' ', tool_call_count = 7, message_count = 1,
api_tokens_total = 100, content_tokens_est_total = 90,
content_tokens_est_assistant = 90, assistant_message_count = 1
WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
conn.execute_compat(
"UPDATE usage_daily
SET source_id = ' ', tool_call_count = 7, message_count = 1,
api_tokens_total = 100, content_tokens_est_total = 90,
content_tokens_est_assistant = 90, assistant_message_count = 1
WHERE workspace_id = ?1",
fparams![workspace_b_id],
)
.unwrap();
}
struct AnalyticsIncidentsFixture {
db_path: PathBuf,
local_source_path: PathBuf,
remote_source_path: PathBuf,
secret_prompt_fragment: &'static str,
}
fn seed_analytics_incidents_fixture(temp_home: &TempDir) -> AnalyticsIncidentsFixture {
const SECRET_PROMPT_FRAGMENT: &str = "sk_live_CASS_INCIDENT_SECRET_0123456789";
let data_dir = temp_home.path().join(".local/share/coding-agent-search");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let local_workspace = temp_home.path().join("local incident workspace");
let remote_workspace = temp_home.path().join("remote incident workspace");
fs::create_dir_all(&local_workspace).unwrap();
fs::create_dir_all(&remote_workspace).unwrap();
let local_source_path = local_workspace.join("incident session's evidence.jsonl");
fs::write(&local_source_path, "{\"fixture\":true}\n").unwrap();
let remote_source_path = remote_workspace.join("remote incident.jsonl");
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let claude_id = storage
.ensure_agent(&sample_agent("claude", "Claude Code"))
.unwrap();
let local_workspace_id = storage
.ensure_workspace(&local_workspace, Some("local-incident-workspace"))
.unwrap();
let remote_workspace_id = storage
.ensure_workspace(&remote_workspace, Some("remote-incident-workspace"))
.unwrap();
let started_at = 1_770_000_000_000_i64;
let mut local = sample_conversation(
"codex",
&local_workspace,
&local_source_path,
"local-incident-session",
"Local incident fixture",
started_at,
vec![
sample_message(
0,
MessageRole::User,
started_at,
&format!(
"health_class=unhealthy recommended_action=cass health; \
err.kind=OpenRead; index-ingest-out-of-memory; \
semantic_fallback_lexical; database is locked in agent_search.db; \
private credential {SECRET_PROMPT_FRAGMENT}"
),
),
sample_message(
1,
MessageRole::Agent,
started_at + 1,
"zero_result_diagnosis found candidate_workspaces for cass search",
),
],
);
local.source_id = "local".to_string();
storage
.insert_conversation_tree(codex_id, Some(local_workspace_id), &local)
.unwrap();
let mut remote = sample_conversation(
"claude",
&remote_workspace,
&remote_source_path,
"remote-incident-session",
"Remote incident fixture",
started_at + 10,
vec![sample_message(
0,
MessageRole::User,
started_at + 10,
"index-ingest-out-of-memory and semantic_fallback_lexical on cass",
)],
);
remote.source_id = "remote-ci".to_string();
remote.origin_host = Some("ts1".to_string());
storage
.insert_conversation_tree(claude_id, Some(remote_workspace_id), &remote)
.unwrap();
drop(storage);
AnalyticsIncidentsFixture {
db_path,
local_source_path,
remote_source_path,
secret_prompt_fragment: SECRET_PROMPT_FRAGMENT,
}
}
fn run_analytics_incidents(temp_home: &TempDir, extra_args: &[&str]) -> (Value, String) {
let mut cmd = base_cmd(temp_home.path());
cmd.args(["analytics", "incidents"]);
cmd.args(extra_args);
cmd.timeout(Duration::from_secs(30));
let output = cmd.output().expect("run analytics incidents");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
output.status.success(),
"analytics incidents failed: status={:?}\nstdout={stdout}\nstderr={stderr}",
output.status.code()
);
let json = serde_json::from_str(stdout.trim()).unwrap_or_else(|error| {
panic!("analytics incidents stdout is not JSON: {error}\n{stdout}")
});
(json, stderr)
}
fn normalize_incident_parity_fields(value: &mut Value) {
value["_meta"]["elapsed_ms"] = json!(0);
value["data"]["discovery"]["elapsed_ms"] = json!(0);
}
// =============================================================================
// Completions command tests
// =============================================================================
#[test]
fn completions_bash_outputs_valid_script() {
let mut cmd = simple_cmd();
cmd.args(["completions", "bash"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Bash completions should contain function definitions
assert!(
stdout.contains("_cass"),
"bash completions should define _cass function"
);
assert!(
stdout.contains("complete"),
"bash completions should have complete command"
);
}
#[test]
fn completions_zsh_outputs_valid_script() {
let mut cmd = simple_cmd();
cmd.args(["completions", "zsh"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Zsh completions should contain compdef
assert!(
stdout.contains("#compdef") || stdout.contains("compdef"),
"zsh completions should have compdef directive"
);
}
#[test]
fn completions_fish_outputs_valid_script() {
let mut cmd = simple_cmd();
cmd.args(["completions", "fish"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Fish completions use complete command
assert!(
stdout.contains("complete -c cass"),
"fish completions should define completions for cass"
);
}
#[test]
fn completions_powershell_outputs_valid_script() {
let mut cmd = simple_cmd();
cmd.args(["completions", "powershell"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// PowerShell completions use Register-ArgumentCompleter
assert!(
stdout.contains("Register-ArgumentCompleter")
|| stdout.contains("ArgumentCompleter")
|| stdout.contains("$scriptblock"),
"powershell completions should define argument completer"
);
}
#[test]
fn completions_help_shows_shells() {
let mut cmd = simple_cmd();
cmd.args(["completions", "--help"]);
cmd.assert()
.success()
.stdout(contains("bash"))
.stdout(contains("zsh"))
.stdout(contains("fish"))
.stdout(contains("powershell"));
}
// =============================================================================
// Man command tests
// =============================================================================
#[test]
fn man_outputs_groff_format() {
let mut cmd = simple_cmd();
cmd.arg("man");
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Man pages start with .TH (title header) or .\" comment
assert!(
stdout.contains(".TH") || stdout.contains(".SH"),
"man output should be groff format with .TH or .SH macros"
);
assert!(
stdout.contains("cass") || stdout.contains("CASS"),
"man page should mention cass"
);
}
#[test]
fn man_help_shows_usage() {
let mut cmd = simple_cmd();
cmd.args(["man", "--help"]);
cmd.assert().success().stdout(contains("Generate man page"));
}
// =============================================================================
// Health command tests
// =============================================================================
#[test]
fn health_json_outputs_valid_structure() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args(["health", "--json", "--data-dir", data_dir.to_str().unwrap()]);
let output = cmd.assert().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output valid JSON with healthy field
if !stdout.trim().is_empty() {
let json: Value = serde_json::from_str(stdout.trim()).expect("valid health json");
assert!(
json.get("healthy").is_some(),
"health JSON should have 'healthy' field"
);
}
}
#[test]
fn health_with_robot_meta_includes_metadata() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
// First create the DB by running index
let mut idx_cmd = base_cmd(tmp.path());
idx_cmd.args(["index", "--data-dir", data_dir.to_str().unwrap(), "--json"]);
idx_cmd.assert().success();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"health",
"--json",
"--robot-meta",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid health json with meta");
// Should have _meta block
assert!(
json.get("_meta").is_some() || json.get("latency_ms").is_some(),
"health --robot-meta should include metadata"
);
}
#[test]
fn health_help_shows_options() {
let mut cmd = simple_cmd();
cmd.args(["health", "--help"]);
cmd.assert()
.success()
.stdout(contains("health check"))
.stdout(contains("--json"))
.stdout(contains("--stale-threshold"));
}
// =============================================================================
// Doctor command tests
// =============================================================================
#[test]
fn doctor_json_outputs_valid_structure() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args(["doctor", "--json", "--data-dir", data_dir.to_str().unwrap()]);
let output = cmd.assert().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output valid JSON
if !stdout.trim().is_empty() {
let json: Value = serde_json::from_str(stdout.trim()).expect("valid doctor json");
// Doctor should have checks or issues array
assert!(
json.get("checks").is_some()
|| json.get("issues").is_some()
|| json.get("status").is_some(),
"doctor JSON should have diagnostic fields"
);
}
}
#[test]
fn doctor_verbose_shows_all_checks() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"doctor",
"--verbose",
"--data-dir",
data_dir.to_str().unwrap(),
]);
// Just check it runs without error
let _ = cmd.assert();
}
#[test]
fn doctor_help_shows_options() {
let mut cmd = simple_cmd();
cmd.args(["doctor", "--help"]);
cmd.assert()
.success()
.stdout(contains("Diagnose"))
.stdout(contains("--fix"))
.stdout(contains("--verbose"));
}
#[test]
fn doctor_fix_preserves_corrupted_archive_bundle_without_repair_plan() {
let tmp = TempDir::new().unwrap();
let temp_home = tmp.path();
let data_dir = temp_home.join("data");
let codex_home = temp_home.join(".codex");
fs::create_dir_all(&data_dir).unwrap();
make_codex_session(&codex_home, "doctor sidecar recovery", 1_733_011_200_000);
let db_path = data_dir.join("agent_search.db");
let corrupt_bytes = b"not a sqlite database".to_vec();
let wal_bytes = b"stale wal bytes".to_vec();
let shm_bytes = b"stale shm bytes".to_vec();
fs::write(&db_path, &corrupt_bytes).unwrap();
fs::write(data_dir.join("agent_search.db-wal"), &wal_bytes).unwrap();
fs::write(data_dir.join("agent_search.db-shm"), &shm_bytes).unwrap();
let doctor = base_cmd(temp_home)
.current_dir(temp_home)
.args([
"doctor",
"--fix",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
])
.output()
.expect("doctor command");
assert!(
!doctor.status.success(),
"safe auto-run must fail closed for unreadable archive repair\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&doctor.stdout),
String::from_utf8_lossy(&doctor.stderr)
);
let doctor_json: Value = serde_json::from_slice(&doctor.stdout).expect("valid doctor json");
assert_eq!(
doctor_json.get("auto_fix_applied").and_then(Value::as_bool),
Some(false),
"legacy safe auto-run must not move or replace archive evidence without a repair plan\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&doctor.stdout),
String::from_utf8_lossy(&doctor.stderr)
);
assert_eq!(
doctor_json["doctor_command"]["realized_subcommand"].as_str(),
Some("safe-auto-run")
);
assert_eq!(
doctor_json["doctor_command"]["execution_mode"].as_str(),
Some("safe-auto-fix")
);
let safe_auto = &doctor_json["safe_auto_eligibility"];
assert_eq!(safe_auto["enabled"].as_bool(), Some(true));
assert!(
safe_auto["manual_approval_required"]
.as_array()
.expect("manual approval actions")
.iter()
.any(|action| action.as_str() == Some("archive_rebuild_from_sources")),
"archive-risk recovery should require a fingerprinted repair plan: {safe_auto:#}"
);
assert!(
doctor_json["checks"]
.as_array()
.expect("doctor checks")
.iter()
.any(|check| {
check["name"].as_str() == Some("safe_auto_archive_rebuild")
&& check["status"].as_str() == Some("fail")
&& check["fix_applied"].as_bool() == Some(false)
}),
"safe auto-run should surface the archive rebuild refusal check: {doctor_json:#}"
);
assert!(
doctor_json["auto_fix_actions"]
.as_array()
.expect("auto fix actions")
.iter()
.all(|action| {
!action
.as_str()
.unwrap_or_default()
.contains("Backed up corrupted database bundle")
}),
"safe auto-run must not report an archive bundle backup/move: {doctor_json:#}"
);
assert_eq!(
fs::read(&db_path).unwrap(),
corrupt_bytes,
"corrupt archive DB bytes must remain in place for later forensic recovery"
);
assert_eq!(
fs::read(data_dir.join("agent_search.db-wal")).unwrap(),
wal_bytes,
"WAL sidecar bytes must remain in place with the archive bundle"
);
assert_eq!(
fs::read(data_dir.join("agent_search.db-shm")).unwrap(),
shm_bytes,
"SHM sidecar bytes must remain in place with the archive bundle"
);
let entries: Vec<String> = fs::read_dir(&data_dir)
.unwrap()
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
assert!(
entries.iter().all(|name| !name.contains(".corrupt.")),
"safe auto-run must not move SQLite DB/WAL/SHM bundles into ad-hoc corrupt backups: {entries:#?}"
);
}
// =============================================================================
// Context command tests
// =============================================================================
#[test]
fn context_requires_path_argument() {
let mut cmd = simple_cmd();
cmd.arg("context");
// Should fail without path
cmd.assert().failure();
}
#[test]
fn context_json_with_nonexistent_path() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
drop(
coding_agent_search::storage::sqlite::FrankenStorage::open(
&data_dir.join("agent_search.db"),
)
.unwrap(),
);
let mut cmd = base_cmd(tmp.path());
cmd.args([
"context",
"/nonexistent/path.jsonl",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
cmd.timeout(Duration::from_secs(10))
.assert()
.code(4)
.stderr(contains("No session found at path"));
}
#[test]
fn context_legacy_nulls_and_zero_limit_remain_readonly_before_migration() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let conn = FrankenConnection::open(db_path.to_string_lossy().as_ref()).unwrap();
conn.execute_batch(
"CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT);
INSERT INTO meta VALUES('schema_version', '20');
CREATE TABLE agents(id INTEGER PRIMARY KEY, slug TEXT);
INSERT INTO agents VALUES(1, NULL);
CREATE TABLE workspaces(id INTEGER PRIMARY KEY, path TEXT);
INSERT INTO workspaces VALUES(1, '/legacy-workspace');
CREATE TABLE conversations(
id INTEGER PRIMARY KEY, agent_id INTEGER, workspace_id INTEGER,
source_path TEXT, title TEXT, started_at INTEGER, source_id TEXT, origin_host TEXT
);
INSERT INTO conversations VALUES
(1, NULL, 1, '/legacy/source.jsonl', NULL, 100, NULL, NULL),
(2, 1, 1, '/legacy/related.jsonl', NULL, 200, ' ', 'user@remote');",
)
.unwrap();
conn.close().unwrap();
let before = fs::read(&db_path).unwrap();
for limit in [1, 0] {
let output = base_cmd(tmp.path())
.args(["context", "/legacy/source.jsonl", "--json", "--limit"])
.arg(limit.to_string())
.arg("--data-dir")
.arg(&data_dir)
.timeout(Duration::from_secs(10))
.assert()
.success()
.get_output()
.clone();
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(payload["source"]["title"], "");
assert_eq!(payload["source"]["agent"], "unknown");
assert_eq!(payload["source"]["source_id"], "local");
assert_eq!(payload["counts"]["same_agent"], 0);
assert_eq!(payload["counts"]["same_workspace"], limit);
assert_eq!(payload["counts"]["same_day"], limit);
if limit == 1 {
let related = &payload["related"]["same_workspace"][0];
assert_eq!(related["path"], "/legacy/related.jsonl");
assert_eq!(related["agent"], "unknown");
assert_eq!(related["title"], "");
assert_eq!(related["source_id"], "user@remote");
}
}
assert_eq!(fs::read(&db_path).unwrap(), before);
}
#[test]
fn context_wide_archive_limit_one_is_bounded_and_readonly() {
use coding_agent_search::storage::sqlite::{CURRENT_SCHEMA_VERSION, FrankenStorage};
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = FrankenStorage::open(&db_path).unwrap();
let conn = storage.raw();
conn.execute_batch(
"DROP INDEX idx_conversations_context;
DELETE FROM _schema_migrations WHERE version >= 21;
UPDATE meta SET value = '20' WHERE key = 'schema_version';
INSERT INTO agents(id, slug, name, kind, created_at, updated_at)
VALUES(1, 'codex', 'Codex', 'cli', 0, 0),
(2, 'claude', 'Claude', 'cli', 0, 0);
INSERT INTO workspaces(id, path) VALUES(1, '/context-workspace'), (2, '/elsewhere');
INSERT INTO sources(id, kind, created_at, updated_at)
VALUES(' local ', 'local', 0, 0), (' ', 'ssh', 0, 0);",
)
.unwrap();
// Reporter shape: 5,600 conversations with about 84 KiB metadata each.
// No message population is needed: context never queries messages.
let metadata = rmp_serde::to_vec_named(&json!({"padding": "x".repeat(84 * 1024)})).unwrap();
for batch in 0..56_i64 {
conn.execute("BEGIN IMMEDIATE").unwrap();
for offset in 1..=100_i64 {
let id = batch * 100 + offset;
conn.execute_compat(
"INSERT INTO conversations
(id, agent_id, workspace_id, source_path, title, started_at, metadata_bin)
VALUES(?1, 2, 2, ?2, 'wide archive session', ?3, ?4)",
fparams![
id,
format!("/wide/{id}.jsonl"),
86_400_000_i64 + id,
metadata.as_slice()
],
)
.unwrap();
}
conn.execute("COMMIT").unwrap();
}
// The source and related sessions are older than all the unrelated rows,
// preventing a newest-first table walk from passing by stopping early.
conn.execute_batch(
"UPDATE conversations SET agent_id = 1, workspace_id = 1, started_at = 100,
title = 'source' WHERE id = 1;
UPDATE conversations SET workspace_id = 1, started_at = 300,
title = NULL, source_id = ' local ' WHERE id = 2;
UPDATE conversations SET agent_id = 1, started_at = 200,
source_id = ' ', origin_host = 'user@remote' WHERE id = 3;",
)
.unwrap();
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").unwrap();
drop(storage);
// Upgrade a populated file through the ordinary writable open, then prove
// every metadata payload survived the index build before invoking the CLI.
let storage = FrankenStorage::open(&db_path).unwrap();
assert_eq!(storage.schema_version().unwrap(), CURRENT_SCHEMA_VERSION);
{
let rows = storage
.raw()
.query("SELECT metadata_bin FROM conversations")
.unwrap();
assert_eq!(rows.len(), 5600);
for row in rows {
assert_eq!(row.get_typed::<Vec<u8>>(0).unwrap(), metadata);
}
}
storage
.raw()
.execute("PRAGMA wal_checkpoint(TRUNCATE)")
.unwrap();
drop(storage);
let before = blake3::hash(&fs::read(&db_path).unwrap());
let wal_path = data_dir.join("agent_search.db-wal");
let wal_before = wal_path.exists().then(|| fs::read(&wal_path).unwrap());
let started = std::time::Instant::now();
let output = base_cmd(tmp.path())
.args([
"context",
"/wide/1.jsonl",
"--json",
"--limit",
"1",
"--data-dir",
])
.arg(&data_dir)
.timeout(Duration::from_secs(30))
.assert()
.success()
.get_output()
.clone();
let elapsed = started.elapsed();
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(payload["source"]["title"], "source");
assert_eq!(
payload["counts"],
json!({"same_workspace": 1, "same_day": 1, "same_agent": 1})
);
let workspace = &payload["related"]["same_workspace"][0];
assert_eq!(workspace["path"], "/wide/2.jsonl");
assert_eq!(workspace["title"], "");
assert_eq!(workspace["agent"], "claude");
assert_eq!(workspace["source_id"], "local");
assert_eq!(payload["related"]["same_day"][0], *workspace);
assert_eq!(payload["related"]["same_agent"][0]["path"], "/wide/3.jsonl");
assert_eq!(
payload["related"]["same_agent"][0]["source_id"],
"user@remote"
);
assert_eq!(
blake3::hash(&fs::read(&db_path).unwrap()),
before,
"context must not migrate or rewrite the archive"
);
assert_eq!(
wal_path.exists().then(|| fs::read(&wal_path).unwrap()),
wal_before,
"context must preserve the WAL too"
);
eprintln!(
"GH463: conversations=5600 metadata_bytes={} limit=1 elapsed_ms={}",
5600 * metadata.len(),
elapsed.as_millis()
);
}
#[test]
fn context_help_shows_options() {
let mut cmd = simple_cmd();
cmd.args(["context", "--help"]);
cmd.assert()
.success()
.stdout(contains("related sessions"))
.stdout(contains("--json"))
.stdout(contains("--limit"));
}
// =============================================================================
// Timeline command tests
// =============================================================================
#[test]
fn timeline_json_outputs_valid_structure() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
// First create DB
let mut idx_cmd = base_cmd(tmp.path());
idx_cmd.args(["index", "--data-dir", data_dir.to_str().unwrap(), "--json"]);
idx_cmd.assert().success();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"timeline",
"--json",
"--today",
"--group-by",
"none",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output valid JSON (may be empty array)
if !stdout.trim().is_empty() {
let _json: Value = serde_json::from_str(stdout.trim()).expect("valid timeline json");
}
}
#[test]
fn timeline_json_normalizes_remote_provenance_without_source_row() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session = tmp.path().join("timeline-remote-no-source-row.jsonl");
fs::write(&session, "{\"session\":\"timeline-remote\"}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
storage
.upsert_source(&coding_agent_search::sources::provenance::Source::remote(
"work-laptop",
"user@work-laptop",
))
.unwrap();
let conn =
coding_agent_search::franken_sync::Connection::open(db_path.to_string_lossy().into_owned())
.unwrap();
conn.execute("UPDATE sources SET kind = '' WHERE id = 'work-laptop'")
.unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let mut conversation = sample_conversation(
"codex",
&workspace,
&session,
"timeline-remote-no-source-row",
"Remote Timeline Session",
now_ms,
vec![
sample_message(0, MessageRole::User, now_ms, "question"),
sample_message(1, MessageRole::Agent, now_ms + 1, "answer"),
],
);
conversation.source_id = "work-laptop".to_string();
conversation.origin_host = Some(" ".to_string());
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &conversation)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"timeline",
"--json",
"--today",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid timeline json");
let sessions: Vec<&Value> = if let Some(items) = json.as_array() {
items.iter().collect()
} else if let Some(items) = json["sessions"].as_array() {
items.iter().collect()
} else {
json["groups"]
.as_object()
.expect("timeline groups object")
.values()
.flat_map(|value| value.as_array().into_iter().flatten())
.collect()
};
let entry = sessions
.into_iter()
.find(|entry| entry["source_path"].as_str() == Some(session.to_string_lossy().as_ref()))
.expect("remote timeline session entry");
assert_eq!(entry["source_id"].as_str(), Some("work-laptop"));
assert_eq!(entry["origin_kind"].as_str(), Some("remote"));
assert!(entry["origin_host"].is_null());
}
#[test]
fn timeline_json_derives_remote_source_id_from_origin_host_when_source_id_blank() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session = tmp.path().join("timeline-blank-source-id.jsonl");
fs::write(&session, "{\"session\":\"timeline-blank-remote\"}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
let conn =
coding_agent_search::franken_sync::Connection::open(db_path.to_string_lossy().into_owned())
.unwrap();
conn.execute(
"INSERT INTO sources(id, kind, host_label, created_at, updated_at) VALUES (' ', 'remote', 'user@work-laptop', 0, 0)",
)
.unwrap();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64;
let mut conversation = sample_conversation(
"codex",
&workspace,
&session,
"timeline-blank-source-id",
"Timeline Blank Source Id",
now_ms,
vec![
sample_message(0, MessageRole::User, now_ms, "question"),
sample_message(1, MessageRole::Agent, now_ms + 1, "answer"),
],
);
conversation.source_id = " ".to_string();
conversation.origin_host = Some("user@work-laptop".to_string());
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &conversation)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"timeline",
"--json",
"--today",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid timeline json");
let sessions: Vec<&Value> = if let Some(items) = json.as_array() {
items.iter().collect()
} else if let Some(items) = json["sessions"].as_array() {
items.iter().collect()
} else {
json["groups"]
.as_object()
.expect("timeline groups object")
.values()
.flat_map(|value| value.as_array().into_iter().flatten())
.collect()
};
let entry = sessions
.into_iter()
.find(|entry| entry["source_path"].as_str() == Some(session.to_string_lossy().as_ref()))
.expect("blank source timeline entry");
assert_eq!(entry["source_id"].as_str(), Some("user@work-laptop"));
assert_eq!(entry["origin_kind"].as_str(), Some("remote"));
assert_eq!(entry["origin_host"].as_str(), Some("user@work-laptop"));
}
#[test]
fn timeline_human_output_does_not_badge_trimmed_local_source_id() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session = tmp.path().join("timeline-trimmed-local.jsonl");
fs::write(&session, "{\"session\":\"timeline-trimmed-local\"}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
storage
.upsert_source(&coding_agent_search::sources::provenance::Source {
id: " local ".to_string(),
kind: coding_agent_search::sources::provenance::SourceKind::Local,
host_label: None,
machine_id: None,
platform: None,
config_json: None,
created_at: None,
updated_at: None,
})
.unwrap();
let mut conversation = sample_conversation(
"codex",
&workspace,
&session,
"timeline-trimmed-local",
"Timeline Trimmed Local",
1_700_000_000_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_000_000, "question"),
sample_message(1, MessageRole::Agent, 1_700_000_000_001, "answer"),
],
);
conversation.source_id = " local ".to_string();
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &conversation)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"timeline",
"--since",
"2020-01-01",
"--until",
"2030-01-01",
"--group-by",
"none",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Timeline Trimmed Local"));
assert!(
!stdout.contains("[ local ]"),
"unexpected raw local badge: {stdout}"
);
assert!(
!stdout.contains("[local]"),
"unexpected normalized local badge: {stdout}"
);
}
#[test]
fn timeline_help_shows_options() {
let mut cmd = simple_cmd();
cmd.args(["timeline", "--help"]);
cmd.assert()
.success()
.stdout(contains("timeline"))
.stdout(contains("--since"))
.stdout(contains("--until"))
.stdout(contains("--today"));
}
// =============================================================================
// Expand command tests
// =============================================================================
#[test]
fn expand_requires_path_and_line() {
let mut cmd = simple_cmd();
cmd.arg("expand");
cmd.assert().failure();
}
#[test]
fn expand_help_shows_options() {
let mut cmd = simple_cmd();
cmd.args(["expand", "--help"]);
cmd.assert()
.success()
.stdout(contains("messages around"))
.stdout(contains("--line"))
.stdout(contains("--context"))
.stdout(contains("--json"));
}
// =============================================================================
// Export command tests
// =============================================================================
#[test]
fn export_requires_path() {
let mut cmd = simple_cmd();
cmd.arg("export");
cmd.assert().failure();
}
#[test]
fn export_help_shows_formats() {
let mut cmd = simple_cmd();
cmd.args(["export", "--help"]);
cmd.assert()
.success()
.stdout(contains("Export"))
.stdout(contains("--format"))
.stdout(contains("--output"))
.stdout(contains("markdown").or(contains("Markdown")));
}
// =============================================================================
// Export-HTML command tests
// =============================================================================
#[test]
fn export_html_requires_session() {
let mut cmd = simple_cmd();
cmd.arg("export-html");
cmd.assert().failure();
}
#[test]
fn export_html_help_shows_encryption_options() {
let mut cmd = simple_cmd();
cmd.args(["export-html", "--help"]);
cmd.assert()
.success()
.stdout(contains("HTML"))
.stdout(contains("--encrypt"))
.stdout(contains("--output-dir"));
}
// =============================================================================
// Sources subcommand tests
// =============================================================================
#[test]
fn sources_list_json_outputs_valid_structure() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args(["sources", "list", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output valid JSON with sources array
let json: Value = serde_json::from_str(stdout.trim()).expect("valid sources list json");
assert!(
json.get("sources").map(|v| v.is_array()).unwrap_or(false) || json.is_object(),
"sources list --json should return object with sources array"
);
}
#[test]
fn sources_list_verbose() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args(["sources", "list", "--verbose"]);
// Should complete without error
cmd.assert().success();
}
#[test]
fn sources_doctor_json_outputs_structure() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args(["sources", "doctor", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output valid JSON
let _json: Value = serde_json::from_str(stdout.trim()).expect("valid sources doctor json");
}
#[test]
fn sources_help_shows_subcommands() {
let mut cmd = simple_cmd();
cmd.args(["sources", "--help"]);
cmd.assert()
.success()
.stdout(contains("list"))
.stdout(contains("add"))
.stdout(contains("remove"))
.stdout(contains("doctor"))
.stdout(contains("sync"))
.stdout(contains("artifact-manifest"));
}
fn write_cli_test_evidence_manifest(
index_path: &Path,
chunk_bytes: &[u8],
) -> EvidenceBundleManifest {
fs::create_dir_all(index_path).expect("create lexical artifact test dir");
fs::write(index_path.join("chunk.bin"), chunk_bytes).expect("write lexical artifact chunk");
let chunk = EvidenceBundleChunk::from_file(
index_path,
"chunk.bin",
EvidenceBundleChunkRole::LexicalShard,
true,
None,
)
.expect("digest lexical artifact chunk");
let mut manifest = EvidenceBundleManifest::new(
"cli-test-lexical-bundle",
EvidenceBundleKind::LexicalGeneration,
0,
);
manifest.chunks.push(chunk);
manifest.save(index_path).expect("save evidence manifest");
manifest
}
fn write_cli_expected_manifest(path: &Path, manifest: &EvidenceBundleManifest) {
fs::write(
path,
serde_json::to_vec_pretty(manifest).expect("serialize expected manifest"),
)
.expect("write expected manifest");
}
#[test]
fn sources_artifact_manifest_verify_existing_json_accepts_complete_manifest() {
let tmp = TempDir::new().unwrap();
let index_path = tmp.path().join("copied-index");
write_cli_test_evidence_manifest(&index_path, b"stable bytes");
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sources",
"artifact-manifest",
"--index-path",
index_path.to_str().unwrap(),
"--verify-existing",
"--json",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid artifact manifest json");
assert_eq!(json["status"], "ok");
assert_eq!(json["verification"]["status"], "complete");
}
#[test]
fn sources_artifact_manifest_verify_existing_json_compares_expected_manifest() {
let tmp = TempDir::new().unwrap();
let index_path = tmp.path().join("copied-index");
let expected_manifest = write_cli_test_evidence_manifest(&index_path, b"producer bytes");
let expected_manifest_path = tmp.path().join("producer-manifest.json");
write_cli_expected_manifest(&expected_manifest_path, &expected_manifest);
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sources",
"artifact-manifest",
"--index-path",
index_path.to_str().unwrap(),
"--verify-existing",
"--expected-manifest",
expected_manifest_path.to_str().unwrap(),
"--json",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid artifact manifest json");
assert_eq!(json["status"], "ok");
assert_eq!(json["verification"]["status"], "complete");
assert_eq!(json["manifest_matches_expected"], true);
assert_eq!(
json["actual_bundle_id"], json["expected_bundle_id"],
"matching producer and copied manifests should report the same bundle id"
);
}
#[test]
fn sources_artifact_manifest_verify_existing_json_rejects_sidecar_rewrite() {
let tmp = TempDir::new().unwrap();
let index_path = tmp.path().join("copied-index");
let expected_manifest = write_cli_test_evidence_manifest(&index_path, b"producer bytes");
let expected_manifest_path = tmp.path().join("producer-manifest.json");
write_cli_expected_manifest(&expected_manifest_path, &expected_manifest);
write_cli_test_evidence_manifest(&index_path, b"tampered bytes");
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sources",
"artifact-manifest",
"--index-path",
index_path.to_str().unwrap(),
"--verify-existing",
"--expected-manifest",
expected_manifest_path.to_str().unwrap(),
"--json",
]);
let output = cmd.assert().failure().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid artifact manifest json");
assert_eq!(json["status"], "error");
assert_eq!(json["verification"]["status"], "complete");
assert_eq!(json["manifest_matches_expected"], false);
}
#[test]
fn sources_artifact_manifest_verify_existing_json_rejects_corrupt_artifact() {
let tmp = TempDir::new().unwrap();
let index_path = tmp.path().join("copied-index");
write_cli_test_evidence_manifest(&index_path, b"stable bytes");
fs::write(index_path.join("chunk.bin"), b"STABLE bytes").expect("mutate artifact chunk");
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sources",
"artifact-manifest",
"--index-path",
index_path.to_str().unwrap(),
"--verify-existing",
"--json",
]);
let output = cmd.assert().failure().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid artifact manifest json");
assert_eq!(json["status"], "error");
assert_eq!(json["verification"]["status"], "unsafe");
assert!(
json["verification"]["issues"]
.as_array()
.expect("issues array")
.iter()
.any(|issue| issue["kind"] == "digest_mismatch")
);
}
// =============================================================================
// Models subcommand tests
// =============================================================================
#[test]
fn models_status_json_outputs_structure() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args(["models", "status", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output valid JSON
let json: Value = serde_json::from_str(stdout.trim()).expect("valid models status json");
assert_eq!(
json["lexical_fail_open"].as_bool(),
Some(true),
"models status should report lexical fail-open behavior"
);
assert!(
json.get("cache_lifecycle").is_some(),
"models status JSON should include cache lifecycle details: {json}"
);
assert!(
json.get("next_step").is_some(),
"models status JSON should include next-step guidance: {json}"
);
assert!(
json["state"].as_str().is_some(),
"models status JSON should include a machine-readable state: {json}"
);
// Keep the older loose structure check for compatibility with callers.
assert!(
json.get("installed").is_some()
|| json.get("models").is_some()
|| json.get("status").is_some()
|| json.is_object(),
"models status JSON should have status information"
);
}
#[test]
fn models_verify_json_with_no_model() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"models",
"verify",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
// May succeed with empty or fail - either is acceptable
let output = cmd.assert().get_output().clone();
let _stdout = String::from_utf8_lossy(&output.stdout);
}
#[test]
fn models_help_shows_subcommands() {
let mut cmd = simple_cmd();
cmd.args(["models", "--help"]);
cmd.assert()
.success()
.stdout(contains("status"))
.stdout(contains("install"))
.stdout(contains("verify"))
.stdout(contains("remove"));
}
#[test]
fn models_install_help_shows_options() {
let mut cmd = simple_cmd();
cmd.args(["models", "install", "--help"]);
cmd.assert()
.success()
.stdout(contains("--model"))
.stdout(contains("--mirror"))
.stdout(contains("--from-file"));
}
// =============================================================================
// Pages command tests
// =============================================================================
#[test]
fn pages_help_shows_options() {
let mut cmd = simple_cmd();
cmd.args(["pages", "--help"]);
cmd.assert()
.success()
.stdout(contains("searchable archive"))
.stdout(contains("--export-only"))
.stdout(contains("--verify"))
.stdout(contains("--no-encryption"))
.stdout(contains("--target"))
.stdout(contains("--project"))
.stdout(contains("--account-id"))
.stdout(contains("--api-token"));
}
#[test]
fn pages_verify_with_nonexistent_path() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args(["pages", "--verify", "/nonexistent/bundle"]);
// Should fail with appropriate error
cmd.assert().failure();
}
// =============================================================================
// Exit code tests
// =============================================================================
#[test]
fn search_requires_query_argument() {
// search command requires a query argument
let mut cmd = simple_cmd();
cmd.arg("search");
// Should fail without query
cmd.assert().failure();
}
#[test]
fn missing_required_arg_returns_error() {
let mut cmd = simple_cmd();
cmd.args(["search"]); // Missing query
cmd.assert().failure();
}
// =============================================================================
// Clap parsing tests for new commands
// =============================================================================
use coding_agent_search::{
AnalyticsBucketing, AnalyticsCommand, Commands, DisplayFormat, RobotFormat,
};
#[test]
fn parse_completions_bash() {
let cli = parse_cli_ok(["cass", "completions", "bash"], "parse completions bash");
match cli.command {
Some(Commands::Completions { shell }) => {
assert_eq!(shell, clap_complete::Shell::Bash);
}
other => panic!("expected completions command, got {other:?}"),
}
}
#[test]
fn parse_health_with_stale_threshold() {
let cli = parse_cli_ok(
["cass", "health", "--stale-threshold", "600"],
"parse health with threshold",
);
match cli.command {
Some(Commands::Health {
stale_threshold, ..
}) => {
assert_eq!(stale_threshold, 600);
}
other => panic!("expected health command, got {other:?}"),
}
}
#[test]
fn parse_pack_robot_contract_flags() {
let cli = parse_cli_ok(
[
"cass",
"--robot-format",
"compact",
"pack",
"checkout failure",
"--json",
"--agent",
"codex",
"--agent",
"claude",
"--workspace",
"/repo",
"--limit",
"40",
"--fields",
"summary,evidence",
"--max-tokens",
"4000",
"--max-sessions",
"5",
"--max-evidence",
"12",
"--context-lines",
"2",
"--max-excerpt-chars",
"800",
"--request-id",
"req-1",
"--display",
"markdown",
"--data-dir",
"/tmp/cass-data",
"--days",
"7",
"--source",
"remote",
"--sessions-from",
"-",
"--mode",
"lexical",
"--freshness-policy",
"strict",
"--freshness-window-seconds",
"3600",
"--require-evidence",
"--explain-selection",
"--refresh",
"--timeout",
"9000",
],
"parse pack robot contract flags",
);
assert_eq!(cli.robot_format, Some(RobotFormat::Compact));
match cli.command {
Some(Commands::Pack {
query,
agent,
workspace,
limit,
json,
fields,
max_tokens,
max_sessions,
max_evidence,
context_lines,
max_excerpt_chars,
request_id,
display,
data_dir,
days,
source,
sessions_from,
mode,
freshness_policy,
freshness_window_seconds,
require_evidence,
explain_selection,
refresh,
timeout,
..
}) => {
assert_eq!(query, "checkout failure");
assert_eq!(agent, vec!["codex", "claude"]);
assert_eq!(workspace, vec!["/repo"]);
assert_eq!(limit, 40);
assert!(json);
assert_eq!(
fields,
Some(vec!["summary".to_string(), "evidence".to_string()])
);
assert_eq!(max_tokens, 4000);
assert_eq!(max_sessions, 5);
assert_eq!(max_evidence, 12);
assert_eq!(context_lines, 2);
assert_eq!(max_excerpt_chars, 800);
assert_eq!(request_id, Some("req-1".to_string()));
assert_eq!(display, Some(DisplayFormat::Markdown));
assert_eq!(data_dir.unwrap().to_str().unwrap(), "/tmp/cass-data");
assert_eq!(days, Some(7));
assert_eq!(source, Some("remote".to_string()));
assert_eq!(sessions_from, Some("-".to_string()));
assert_eq!(
mode,
Some(coding_agent_search::search::query::SearchMode::Lexical)
);
assert_eq!(freshness_policy, "strict");
assert_eq!(freshness_window_seconds, 3600);
assert!(require_evidence);
assert!(explain_selection);
assert!(refresh);
assert_eq!(timeout, Some(9000));
}
other => panic!("expected pack command, got {other:?}"),
}
}
fn assert_pack_robot_error(args: &[&str], stdin: Option<&str>, expected_kind: &str) {
let mut cmd = simple_cmd();
cmd.args(args);
if let Some(stdin) = stdin {
cmd.write_stdin(stdin);
}
let output = cmd.assert().failure().get_output().clone();
assert!(
output.stdout.is_empty(),
"pack robot errors must keep stdout data-only, got stdout={}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8_lossy(&output.stderr);
let json: Value = serde_json::from_str(stderr.trim())
.unwrap_or_else(|err| panic!("stderr should be a JSON error envelope: {err}\n{stderr}"));
assert_eq!(json["error"]["kind"], expected_kind);
}
#[test]
fn pack_empty_query_json_error_uses_stderr_only() {
assert_pack_robot_error(&["pack", "", "--json"], None, "pack-empty-query");
}
#[test]
fn pack_rejects_sessions_robot_format_before_search() {
assert_pack_robot_error(
&["--robot-format", "sessions", "pack", "checkout", "--json"],
None,
"pack-unsupported-format",
);
}
#[test]
fn pack_invalid_field_with_sessions_from_stdin_is_json_error() {
assert_pack_robot_error(
&[
"pack",
"checkout",
"--json",
"--sessions-from",
"-",
"--fields",
"no_such_field",
],
Some("/tmp/session-a.jsonl\n"),
"pack-invalid-field",
);
}
#[test]
fn parse_doctor_with_fix() {
let cli = parse_cli_ok(
["cass", "doctor", "--fix", "--verbose"],
"parse doctor with fix",
);
match cli.command {
Some(Commands::Doctor { fix, verbose, .. }) => {
assert!(fix, "fix should be true");
assert!(verbose, "verbose should be true");
}
other => panic!("expected doctor command, got {other:?}"),
}
}
#[test]
fn parse_timeline_with_filters() {
let cli = parse_cli_ok(
[
"cass",
"timeline",
"--since",
"2024-01-01",
"--agent",
"claude",
],
"parse timeline with filters",
);
match cli.command {
Some(Commands::Timeline { since, agent, .. }) => {
assert_eq!(since, Some("2024-01-01".to_string()));
assert_eq!(agent, vec!["claude"]);
}
other => panic!("expected timeline command, got {other:?}"),
}
}
#[test]
fn parse_expand_with_context() {
let cli = parse_cli_ok(
[
"cass",
"expand",
"/path/to/session.jsonl",
"--line",
"100",
"-C",
"5",
],
"parse expand with context",
);
match cli.command {
Some(Commands::Expand {
path,
line,
context,
..
}) => {
assert_eq!(path.to_str().unwrap(), "/path/to/session.jsonl");
assert_eq!(line, Some(100));
assert_eq!(context, 5);
}
other => panic!("expected expand command, got {other:?}"),
}
}
#[test]
fn parse_context_with_limit() {
let cli = parse_cli_ok(
["cass", "context", "/path/to/session.jsonl", "--limit", "10"],
"parse context with limit",
);
match cli.command {
Some(Commands::Context { path, limit, .. }) => {
assert_eq!(path.to_str().unwrap(), "/path/to/session.jsonl");
assert_eq!(limit, 10);
}
other => panic!("expected context command, got {other:?}"),
}
}
#[test]
fn parse_sessions_with_workspace_and_limit() {
let cli = parse_cli_ok(
[
"cass",
"sessions",
"--workspace",
"/path/to/project",
"--limit",
"3",
"--json",
],
"parse sessions with workspace and limit",
);
match cli.command {
Some(Commands::Sessions {
workspace,
current,
limit,
json,
..
}) => {
assert_eq!(workspace.unwrap().to_str().unwrap(), "/path/to/project");
assert!(!current);
assert_eq!(limit, Some(3));
assert!(json);
}
other => panic!("expected sessions command, got {other:?}"),
}
}
#[test]
fn sessions_json_reports_recent_and_current_workspace_sessions() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace_a = tmp.path().join("workspace-a");
let workspace_a_nested = workspace_a.join("src");
let workspace_b = tmp.path().join("workspace-b");
fs::create_dir_all(&workspace_a_nested).unwrap();
fs::create_dir_all(&workspace_b).unwrap();
let session_a_old = tmp.path().join("claude-old.jsonl");
let session_a_new = tmp.path().join("claude-new.jsonl");
let session_b = tmp.path().join("codex.jsonl");
fs::write(&session_a_old, "{\"session\":\"old\"}\n").unwrap();
std::thread::sleep(Duration::from_millis(5));
fs::write(&session_a_new, "{\"session\":\"new\"}\n").unwrap();
std::thread::sleep(Duration::from_millis(5));
fs::write(&session_b, "{\"session\":\"other\"}\n").unwrap();
let claude_id = storage
.ensure_agent(&sample_agent("claude_code", "Claude Code"))
.unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_a_id = storage
.ensure_workspace(&workspace_a, Some("workspace-a"))
.unwrap();
let workspace_b_id = storage
.ensure_workspace(&workspace_b, Some("workspace-b"))
.unwrap();
storage
.insert_conversation_tree(
claude_id,
Some(workspace_a_id),
&sample_conversation(
"claude_code",
&workspace_a,
&session_a_old,
"claude-old",
"Old Claude Session",
1_700_000_000_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_000_000, "old question"),
sample_message(1, MessageRole::Agent, 1_700_000_000_001, "old answer"),
],
),
)
.unwrap();
storage
.insert_conversation_tree(
claude_id,
Some(workspace_a_id),
&sample_conversation(
"claude_code",
&workspace_a,
&session_a_new,
"claude-new",
"Newest Claude Session",
1_700_000_100_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_100_000, "new question"),
sample_message(1, MessageRole::Agent, 1_700_000_100_001, "new answer"),
],
),
)
.unwrap();
storage
.insert_conversation_tree(
codex_id,
Some(workspace_b_id),
&sample_conversation(
"codex",
&workspace_b,
&session_b,
"codex-other",
"Other Workspace Session",
1_700_000_200_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_200_000, "other question"),
sample_message(1, MessageRole::Agent, 1_700_000_200_001, "other answer"),
],
),
)
.unwrap();
let mut all_cmd = base_cmd(tmp.path());
all_cmd.args([
"sessions",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let all_output = all_cmd.assert().success().get_output().clone();
let all_json: Value = serde_json::from_slice(&all_output.stdout).expect("valid sessions json");
let all_sessions = all_json["sessions"].as_array().expect("sessions array");
assert_eq!(all_sessions.len(), 3, "should list all recent sessions");
assert_eq!(
all_sessions[0]["path"].as_str().unwrap(),
session_b.to_string_lossy(),
"most recently modified file should come first"
);
assert_eq!(all_sessions[0]["message_count"], 2);
assert_eq!(all_sessions[0]["human_turns"], 1);
assert_eq!(all_sessions[0]["source_id"].as_str(), Some("local"));
assert!(all_sessions[0]["origin_host"].is_null());
assert!(all_sessions[0]["size_bytes"].is_number());
let mut current_cmd = base_cmd(tmp.path());
current_cmd.current_dir(&workspace_a_nested);
current_cmd.args([
"sessions",
"--current",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let current_output = current_cmd.assert().success().get_output().clone();
let current_json: Value =
serde_json::from_slice(&current_output.stdout).expect("valid current sessions json");
let current_sessions = current_json["sessions"].as_array().expect("sessions array");
assert_eq!(
current_sessions.len(),
1,
"--current should return one best match"
);
assert_eq!(
current_sessions[0]["path"].as_str().unwrap(),
session_a_new.to_string_lossy(),
"current workspace should resolve to newest matching workspace session"
);
assert_eq!(
current_sessions[0]["workspace"].as_str().unwrap(),
workspace_a.to_string_lossy()
);
}
/// cass#323: `--limit` must truncate the result (counts are backfilled only
/// for surviving sessions) and `--since` must bound the enumeration window
/// by recorded activity timestamps.
#[test]
fn sessions_json_since_bounds_window_and_limit_truncates() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session_old = tmp.path().join("since-old.jsonl");
let session_new = tmp.path().join("since-new.jsonl");
fs::write(&session_old, "{\"session\":\"old\"}\n").unwrap();
std::thread::sleep(Duration::from_millis(5));
fs::write(&session_new, "{\"session\":\"new\"}\n").unwrap();
let claude_id = storage
.ensure_agent(&sample_agent("claude_code", "Claude Code"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
// Old session: started/ended 2023-11-14.
let old_started = 1_700_000_000_000_i64;
storage
.insert_conversation_tree(
claude_id,
Some(workspace_id),
&sample_conversation(
"claude_code",
&workspace,
&session_old,
"since-old",
"Old Session",
old_started,
vec![
sample_message(0, MessageRole::User, old_started, "old question"),
sample_message(1, MessageRole::Agent, old_started + 1, "old answer"),
],
),
)
.unwrap();
// New session: started/ended 2024-12-24.
let new_started = 1_735_000_000_000_i64;
storage
.insert_conversation_tree(
claude_id,
Some(workspace_id),
&sample_conversation(
"claude_code",
&workspace,
&session_new,
"since-new",
"New Session",
new_started,
vec![
sample_message(0, MessageRole::User, new_started, "new question"),
sample_message(1, MessageRole::Agent, new_started + 1, "new answer"),
sample_message(2, MessageRole::User, new_started + 2, "follow-up"),
],
),
)
.unwrap();
// --since between the two sessions keeps only the newer one.
let mut since_cmd = base_cmd(tmp.path());
since_cmd.args([
"sessions",
"--json",
"--since",
"2024-06-01",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let since_output = since_cmd.assert().success().get_output().clone();
let since_json: Value =
serde_json::from_slice(&since_output.stdout).expect("valid sessions json");
let since_sessions = since_json["sessions"].as_array().expect("sessions array");
assert_eq!(
since_sessions.len(),
1,
"--since should exclude the session that ended before the cutoff"
);
assert_eq!(
since_sessions[0]["path"].as_str().unwrap(),
session_new.to_string_lossy()
);
// Counts are backfilled after the limit push-down; they must stay exact.
assert_eq!(since_sessions[0]["message_count"], 3);
assert_eq!(since_sessions[0]["human_turns"], 2);
// --limit 1 returns exactly one session, with exact counts.
let mut limit_cmd = base_cmd(tmp.path());
limit_cmd.args([
"sessions",
"--json",
"--limit",
"1",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let limit_output = limit_cmd.assert().success().get_output().clone();
let limit_json: Value =
serde_json::from_slice(&limit_output.stdout).expect("valid sessions json");
let limit_sessions = limit_json["sessions"].as_array().expect("sessions array");
assert_eq!(limit_sessions.len(), 1, "--limit 1 should truncate to one");
assert_eq!(
limit_sessions[0]["path"].as_str().unwrap(),
session_new.to_string_lossy(),
"most recently modified session should win the single slot"
);
assert_eq!(limit_sessions[0]["message_count"], 3);
assert_eq!(limit_sessions[0]["human_turns"], 2);
// An unparseable --since is a usage error, not a silent full scan.
let mut bad_cmd = base_cmd(tmp.path());
bad_cmd.args([
"sessions",
"--json",
"--since",
"not-a-date",
"--data-dir",
data_dir.to_str().unwrap(),
]);
bad_cmd.assert().failure();
}
#[test]
fn sessions_json_keeps_local_file_metadata_for_trimmed_local_source_id() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session = tmp.path().join("trimmed-local.jsonl");
fs::write(&session, "{\"session\":\"trimmed-local\"}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
storage
.upsert_source(&coding_agent_search::sources::provenance::Source {
id: " local ".to_string(),
kind: coding_agent_search::sources::provenance::SourceKind::Local,
host_label: None,
machine_id: None,
platform: None,
config_json: None,
created_at: None,
updated_at: None,
})
.unwrap();
let mut conversation = sample_conversation(
"codex",
&workspace,
&session,
"trimmed-local",
"Trimmed Local Session",
1_700_000_000_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_000_000, "question"),
sample_message(1, MessageRole::Agent, 1_700_000_000_001, "answer"),
],
);
conversation.source_id = " local ".to_string();
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &conversation)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sessions",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid sessions json");
let sessions = json["sessions"].as_array().expect("sessions array");
let entry = sessions
.iter()
.find(|entry| entry["path"].as_str() == Some(session.to_string_lossy().as_ref()))
.expect("trimmed local session entry");
assert_eq!(entry["source_id"].as_str(), Some("local"));
assert!(
entry["size_bytes"].is_number(),
"expected local metadata for trimmed local source"
);
assert!(
entry["modified"].is_string(),
"expected modified timestamp for trimmed local source"
);
}
#[test]
fn sessions_json_derives_remote_source_id_from_origin_host_when_source_id_blank() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session = tmp.path().join("remote-blank-source-id.jsonl");
fs::write(&session, "{\"session\":\"remote\"}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
let conn =
coding_agent_search::franken_sync::Connection::open(db_path.to_string_lossy().into_owned())
.unwrap();
conn.execute(
"INSERT INTO sources(id, kind, host_label, created_at, updated_at) VALUES (' ', 'remote', 'user@work-laptop', 0, 0)",
)
.unwrap();
let mut conversation = sample_conversation(
"codex",
&workspace,
&session,
"remote-blank-source-id",
"Remote Blank Source Id",
1_700_000_000_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_000_000, "question"),
sample_message(1, MessageRole::Agent, 1_700_000_000_001, "answer"),
],
);
conversation.source_id = " ".to_string();
conversation.origin_host = Some("user@work-laptop".to_string());
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &conversation)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sessions",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid sessions json");
let sessions = json["sessions"].as_array().expect("sessions array");
let entry = sessions
.iter()
.find(|entry| entry["path"].as_str() == Some(session.to_string_lossy().as_ref()))
.expect("remote blank source session entry");
assert_eq!(entry["source_id"].as_str(), Some("user@work-laptop"));
assert_eq!(entry["origin_host"].as_str(), Some("user@work-laptop"));
assert!(
entry["size_bytes"].is_null(),
"remote fallback source_id must not be treated as local metadata"
);
}
#[test]
fn sessions_json_keeps_local_file_metadata_for_blank_source_id() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session = tmp.path().join("blank-local-source-id.jsonl");
fs::write(&session, "{\"session\":\"blank-local\"}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
let conn =
coding_agent_search::franken_sync::Connection::open(db_path.to_string_lossy().into_owned())
.unwrap();
conn.execute(
"INSERT INTO sources(id, kind, host_label, created_at, updated_at) VALUES (' ', 'local', NULL, 0, 0)",
)
.unwrap();
let mut conversation = sample_conversation(
"codex",
&workspace,
&session,
"blank-local-source-id",
"Blank Local Source Id",
1_700_000_000_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_000_000, "question"),
sample_message(1, MessageRole::Agent, 1_700_000_000_001, "answer"),
],
);
conversation.source_id = " ".to_string();
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &conversation)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sessions",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid sessions json");
let sessions = json["sessions"].as_array().expect("sessions array");
let entry = sessions
.iter()
.find(|entry| entry["path"].as_str() == Some(session.to_string_lossy().as_ref()))
.expect("blank local session entry");
assert_eq!(entry["source_id"].as_str(), Some("local"));
assert!(
entry["size_bytes"].is_number(),
"blank local source_id should still resolve to local file metadata"
);
assert!(entry["modified"].is_string());
}
#[test]
fn sessions_json_trims_blank_remote_origin_host() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let session = tmp.path().join("remote-blank-origin-host.jsonl");
fs::write(&session, "{\"session\":\"remote\"}\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
storage
.upsert_source(&coding_agent_search::sources::provenance::Source::remote(
"work-laptop",
"user@work-laptop",
))
.unwrap();
let mut conversation = sample_conversation(
"codex",
&workspace,
&session,
"remote-blank-origin-host",
"Remote Blank Origin Host",
1_700_000_000_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_000_000, "question"),
sample_message(1, MessageRole::Agent, 1_700_000_000_001, "answer"),
],
);
conversation.source_id = "work-laptop".to_string();
conversation.origin_host = Some(" ".to_string());
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &conversation)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sessions",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid sessions json");
let sessions = json["sessions"].as_array().expect("sessions array");
let entry = sessions
.iter()
.find(|entry| entry["path"].as_str() == Some(session.to_string_lossy().as_ref()))
.expect("remote session entry");
assert_eq!(entry["source_id"].as_str(), Some("work-laptop"));
assert!(
entry["origin_host"].is_null(),
"blank origin_host should be trimmed away so downstream displays fall back to source_id"
);
}
#[test]
fn sessions_json_distinguishes_same_path_across_sources() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
let shared_path = tmp.path().join("shared-session.jsonl");
fs::write(&shared_path, "{\"session\":\"shared\"}\\n").unwrap();
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.unwrap();
let workspace_id = storage
.ensure_workspace(&workspace, Some("workspace"))
.unwrap();
storage
.upsert_source(&coding_agent_search::sources::provenance::Source::remote(
"laptop",
"user@laptop",
))
.unwrap();
storage
.insert_conversation_tree(
codex_id,
Some(workspace_id),
&sample_conversation(
"codex",
&workspace,
&shared_path,
"shared-local",
"Shared Session",
1_700_000_000_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_000_000, "local question"),
sample_message(1, MessageRole::Agent, 1_700_000_000_001, "local answer"),
],
),
)
.unwrap();
let mut remote = sample_conversation(
"codex",
&workspace,
&shared_path,
"shared-remote",
"Shared Session",
1_700_000_100_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_100_000, "remote question"),
sample_message(1, MessageRole::Agent, 1_700_000_100_001, "remote answer"),
],
);
remote.source_id = "laptop".to_string();
remote.origin_host = Some("user@laptop".to_string());
storage
.insert_conversation_tree(codex_id, Some(workspace_id), &remote)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.args([
"sessions",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid sessions json");
let sessions = json["sessions"].as_array().expect("sessions array");
let shared: Vec<&Value> = sessions
.iter()
.filter(|entry| entry["path"].as_str() == Some(shared_path.to_string_lossy().as_ref()))
.collect();
assert_eq!(shared.len(), 2, "same-path sessions should both be visible");
assert!(
shared
.iter()
.any(|entry| entry["source_id"].as_str() == Some("local"))
);
assert!(
shared
.iter()
.any(|entry| entry["source_id"].as_str() == Some("laptop"))
);
assert!(
shared
.iter()
.any(|entry| entry["origin_host"].as_str() == Some("user@laptop"))
);
}
#[test]
fn sessions_current_prefers_closest_workspace_over_newer_parent_workspace() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path).unwrap();
let workspace_root = tmp.path().join("repo");
let workspace_nested = workspace_root.join("apps/web");
let cwd = workspace_nested.join("src/components");
fs::create_dir_all(&cwd).unwrap();
let nested_session_path = tmp.path().join("nested.jsonl");
let root_session_path = tmp.path().join("root.jsonl");
fs::write(&nested_session_path, "{\"session\":\"nested\"}\n").unwrap();
std::thread::sleep(Duration::from_millis(5));
fs::write(&root_session_path, "{\"session\":\"root\"}\n").unwrap();
let claude_id = storage
.ensure_agent(&sample_agent("claude_code", "Claude Code"))
.unwrap();
let workspace_root_id = storage
.ensure_workspace(&workspace_root, Some("repo"))
.unwrap();
let workspace_nested_id = storage
.ensure_workspace(&workspace_nested, Some("repo-web"))
.unwrap();
storage
.insert_conversation_tree(
claude_id,
Some(workspace_nested_id),
&sample_conversation(
"claude_code",
&workspace_nested,
&nested_session_path,
"nested-session",
"Nested Session",
1_700_000_100_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_100_000, "nested question"),
sample_message(1, MessageRole::Agent, 1_700_000_100_001, "nested answer"),
],
),
)
.unwrap();
storage
.insert_conversation_tree(
claude_id,
Some(workspace_root_id),
&sample_conversation(
"claude_code",
&workspace_root,
&root_session_path,
"root-session",
"Root Session",
1_700_000_200_000,
vec![
sample_message(0, MessageRole::User, 1_700_000_200_000, "root question"),
sample_message(1, MessageRole::Agent, 1_700_000_200_001, "root answer"),
],
),
)
.unwrap();
let mut cmd = base_cmd(tmp.path());
cmd.current_dir(&cwd);
cmd.args([
"sessions",
"--current",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&output.stdout).expect("valid current sessions json");
let sessions = json["sessions"].as_array().expect("sessions array");
assert_eq!(sessions.len(), 1, "--current should default to one session");
assert_eq!(
sessions[0]["path"].as_str().unwrap(),
nested_session_path.to_string_lossy(),
"closest matching workspace should win over a newer parent workspace session"
);
assert_eq!(
sessions[0]["workspace"].as_str().unwrap(),
workspace_nested.to_string_lossy()
);
}
#[test]
fn parse_export_with_format() {
let cli = parse_cli_ok(
[
"cass",
"export",
"/path/to/session.jsonl",
"--format",
"json",
],
"parse export with format",
);
match cli.command {
Some(Commands::Export { path, format, .. }) => {
assert_eq!(path.to_str().unwrap(), "/path/to/session.jsonl");
assert_eq!(format, coding_agent_search::ConvExportFormat::Json);
}
other => panic!("expected export command, got {other:?}"),
}
}
#[test]
fn parse_export_html_with_encrypt() {
let cli = parse_cli_ok(
[
"cass",
"export-html",
"/path/to/session.jsonl",
"--encrypt",
"--password-stdin",
],
"parse export-html with encrypt",
);
match cli.command {
Some(Commands::ExportHtml {
session,
encrypt,
password_stdin,
..
}) => {
assert_eq!(session.to_str().unwrap(), "/path/to/session.jsonl");
assert!(encrypt);
assert!(password_stdin);
}
other => panic!("expected export-html command, got {other:?}"),
}
}
#[test]
fn parse_export_html_rejects_password_argv() {
let err = parse_cli_err(
[
"cass",
"export-html",
"/path/to/session.jsonl",
"--encrypt",
"--password",
"secret",
],
"export-html should reject argv password input",
);
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
assert!(err.to_string().contains("--password"));
}
// =============================================================================
// Analytics CLI scaffolding tests (br-z9fse.3.1)
// =============================================================================
#[test]
fn analytics_help_lists_expected_subcommands() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "--help"]);
cmd.assert()
.success()
.stdout(contains("status"))
.stdout(contains("tokens"))
.stdout(contains("tools"))
.stdout(contains("models"))
.stdout(contains("incidents"))
.stdout(contains("rebuild"))
.stdout(contains("validate"));
}
#[test]
fn analytics_tokens_help_lists_shared_flags_and_group_by() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--help"]);
cmd.assert()
.success()
.stdout(contains("--since"))
.stdout(contains("--until"))
.stdout(contains("--days"))
.stdout(contains("--agent"))
.stdout(contains("--workspace"))
.stdout(contains("--source"))
.stdout(contains("--json"))
.stdout(contains("--group-by"));
}
#[test]
fn analytics_subcommands_emit_uniform_json_envelope() {
let tmp_home = TempDir::new().expect("temp home");
let data_dir = tmp_home.path().join("cass_data");
fs::create_dir_all(&data_dir).expect("create data dir");
let data_dir_str = data_dir.to_string_lossy().to_string();
// Create an empty-but-valid cass database so analytics commands can open
// it without requiring a full `cass index --full` run.
let db_path = data_dir.join("agent_search.db");
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&db_path)
.expect("create cass db");
drop(storage);
let shared_query: Vec<&str> = vec![
"--json",
"--since",
"2026-01-01",
"--until",
"2026-01-31",
"--agent",
"claude",
"--workspace",
"/tmp/project-a",
"--source",
"local",
"--data-dir",
data_dir_str.as_str(),
];
let cases: Vec<(&str, Vec<&str>)> = vec![
("analytics/status", vec!["analytics", "status"]),
(
"analytics/tokens",
vec!["analytics", "tokens", "--group-by", "day"],
),
(
"analytics/tools",
vec!["analytics", "tools", "--group-by", "week"],
),
(
"analytics/models",
vec!["analytics", "models", "--group-by", "month"],
),
("analytics/incidents", vec!["analytics", "incidents"]),
("analytics/rebuild", vec!["analytics", "rebuild", "--force"]),
("analytics/validate", vec!["analytics", "validate", "--fix"]),
];
// Commands that may fail due to DB lock contention in multi-agent environments.
let lock_sensitive_commands = ["analytics/rebuild"];
for (expected_command, mut args) in cases {
match expected_command {
// Rebuild is a whole-rollup operation. It supports a lower time
// bound for Track A but rejects query-only dimensional filters.
"analytics/rebuild" => args.extend_from_slice(&[
"--json",
"--since",
"2026-01-01",
"--data-dir",
data_dir_str.as_str(),
]),
// Validation checks global invariants and cannot apply query filters.
"analytics/validate" => {
args.extend_from_slice(&["--json", "--data-dir", data_dir_str.as_str()])
}
_ => args.extend_from_slice(&shared_query),
}
let mut cmd = base_cmd(tmp_home.path());
cmd.args(&args);
let output = cmd.output().expect("failed to execute command");
// Rebuild may fail with exit 9 ("database is locked") when other processes
// hold the DB — skip validation for this transient case.
if !output.status.success() && lock_sensitive_commands.contains(&expected_command) {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("database is locked") {
eprintln!("Skipping {expected_command}: DB locked (transient, not a test failure)");
continue;
}
panic!(
"unexpected failure for {expected_command}: exit={:?} stderr={stderr}",
output.status.code()
);
}
assert!(
output.status.success(),
"{expected_command} exited with code {:?}",
output.status.code()
);
let stdout = String::from_utf8_lossy(&output.stdout);
// Note: some analytics subcommands (rebuild, validate, models) emit
// human-readable diagnostics to stderr even in --json mode. This is by design
// — stderr carries diagnostics, stdout carries structured JSON.
let json: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!("invalid JSON for {expected_command}: {e}\nstdout={stdout}")
});
assert_eq!(json["command"], expected_command);
let data = &json["data"];
match expected_command {
"analytics/status" => {
assert!(
data["tables"].is_array(),
"analytics/status should expose table stats: {json}"
);
assert!(
data["coverage"].is_object(),
"analytics/status should expose coverage block: {json}"
);
assert!(
data["drift"].is_object(),
"analytics/status should expose drift block: {json}"
);
}
"analytics/tokens" => {
assert!(
data["buckets"].is_array(),
"analytics/tokens should expose bucketed rows: {json}"
);
assert!(
data["_meta"].is_object(),
"analytics/tokens should include _meta block: {json}"
);
}
"analytics/tools" => {
assert!(
data["rows"].is_array(),
"analytics/tools should expose rows: {json}"
);
}
"analytics/models" => {
assert!(
data["by_api_tokens"].is_object(),
"analytics/models should expose by_api_tokens: {json}"
);
}
"analytics/incidents" => {
assert_eq!(data["schema_version"], 2);
assert!(
data["discovery"].is_object(),
"analytics/incidents should expose bounded discovery: {json}"
);
assert!(
data["top_sessions"].is_array(),
"analytics/incidents should expose top_sessions: {json}"
);
assert!(
data["redaction"].is_object(),
"analytics/incidents should expose redaction provenance: {json}"
);
}
"analytics/rebuild" => {
assert!(
data["track"].is_string(),
"analytics/rebuild should expose track: {json}"
);
assert!(
data["tracks_rebuilt"].is_array(),
"analytics/rebuild should expose tracks_rebuilt: {json}"
);
}
"analytics/validate" => {
assert!(
data["summary"].is_object(),
"analytics/validate should expose summary: {json}"
);
assert!(
data["checks"].is_array(),
"analytics/validate should expose checks: {json}"
);
}
_ => panic!("unexpected analytics subcommand: {expected_command}"),
}
assert!(
json["_meta"]["elapsed_ms"].as_u64().is_some(),
"missing numeric elapsed_ms for {expected_command}: {json}"
);
let filters = json["_meta"]["filters_applied"]
.as_array()
.expect("filters_applied array");
if expected_command == "analytics/validate" {
assert!(
filters.is_empty(),
"analytics/validate must not claim query filters were applied: {json}"
);
} else {
assert!(
!filters.is_empty(),
"filters_applied should include supported filters for {expected_command}"
);
}
}
}
#[test]
fn parse_analytics_tokens_with_shared_flags() {
let cli = parse_cli_ok(
[
"cass",
"analytics",
"tokens",
"--group-by",
"week",
"--since",
"2026-01-01",
"--until",
"2026-01-31",
"--days",
"7",
"--agent",
"claude",
"--agent",
"codex",
"--workspace",
"/tmp/ws-a",
"--workspace",
"/tmp/ws-b",
"--source",
"remote",
"--json",
],
"parse analytics tokens with shared flags",
);
match cli.command {
Some(Commands::Analytics(AnalyticsCommand::Tokens { common, group_by })) => {
assert_eq!(group_by, AnalyticsBucketing::Week);
assert_eq!(common.since.as_deref(), Some("2026-01-01"));
assert_eq!(common.until.as_deref(), Some("2026-01-31"));
assert_eq!(common.days, Some(7));
assert_eq!(common.agent, vec!["claude", "codex"]);
assert_eq!(common.workspace, vec!["/tmp/ws-a", "/tmp/ws-b"]);
assert_eq!(common.source.as_deref(), Some("remote"));
assert!(common.json);
}
other => panic!("expected analytics tokens command, got {other:?}"),
}
}
#[test]
fn parse_analytics_incidents_with_caps_and_shared_flags() {
let cli = parse_cli_ok(
[
"cass",
"analytics",
"incidents",
"--limit",
"7",
"--max-sessions",
"11",
"--max-messages",
"222",
"--max-bytes",
"3333",
"--budget-ms",
"4444",
"--agent",
"codex",
"--workspace",
"/tmp/incident-workspace",
"--source",
"remote-ci",
"--json",
],
"parse analytics incidents with caps and shared flags",
);
match cli.command {
Some(Commands::Analytics(AnalyticsCommand::Incidents {
common,
limit,
max_sessions,
max_messages,
max_bytes,
budget_ms,
})) => {
assert_eq!(limit, 7);
assert_eq!(max_sessions, 11);
assert_eq!(max_messages, 222);
assert_eq!(max_bytes, 3333);
assert_eq!(budget_ms, 4444);
assert_eq!(common.agent, vec!["codex"]);
assert_eq!(common.workspace, vec!["/tmp/incident-workspace"]);
assert_eq!(common.source.as_deref(), Some("remote-ci"));
assert!(common.json);
}
other => panic!("expected analytics incidents command, got {other:?}"),
}
}
#[test]
fn analytics_incidents_robot_output_is_redacted_provenanced_and_read_only() {
let tmp = TempDir::new().unwrap();
let fixture = seed_analytics_incidents_fixture(&tmp);
let db_before = fs::read(&fixture.db_path).expect("read database before incident scan");
let (json, stderr) = run_analytics_incidents(
&tmp,
&[
"--agent",
"codex",
"--source",
"local",
"--limit",
"5",
"--max-sessions",
"10",
"--max-messages",
"100",
"--max-bytes",
"1048576",
"--budget-ms",
"60000",
"--json",
],
);
let db_after = fs::read(&fixture.db_path).expect("read database after incident scan");
assert_eq!(
db_after, db_before,
"incident analytics must not mutate the DB"
);
assert!(
stderr.trim().is_empty(),
"robot stderr should be quiet: {stderr}"
);
assert_eq!(json["command"], "analytics/incidents");
assert_eq!(json["data"]["schema_version"], 2);
assert_eq!(json["data"]["count_scope"], "all_matching_candidates");
assert_eq!(
json["data"]["scan_units"],
json!({
"files": "archive_conversations",
"lines": "archive_messages",
"bytes": "utf8_message_content_inspected",
"candidate_order": "newest_archive_row_first",
"message_fragment_chars": 4096
})
);
assert_eq!(json["data"]["discovery"]["partial"], false);
assert_eq!(json["data"]["discovery"]["stop_reason"], "completed");
assert_eq!(json["data"]["total_sessions"], 1);
assert!(json["data"]["total_hits"].as_u64().unwrap_or(0) >= 5);
assert_eq!(json["data"]["top_sessions_truncated"], false);
let sessions = json["data"]["top_sessions"]
.as_array()
.expect("top_sessions array");
assert_eq!(sessions.len(), 1);
let session = &sessions[0];
let source_path = fixture.local_source_path.to_string_lossy().to_string();
assert_eq!(session["session_id"], "local-incident-session");
assert_eq!(session["agent"], "codex");
assert_eq!(session["host"], "local");
assert_eq!(session["source_path"], source_path);
assert_eq!(session["source_id"], "local");
assert_eq!(session["exists_state"], "exists");
assert_eq!(session["redaction_status"], "redacted");
assert!(session["category_breadth"].as_u64().unwrap_or(0) >= 5);
assert!(
session["dominant_categories"]
.as_array()
.is_some_and(|categories| !categories.is_empty())
);
assert_eq!(session["suggested_command"]["kind"], "view");
let conversation_id = session["conversation_id"]
.as_i64()
.expect("canonical conversation id");
let canonical_db_path = fs::canonicalize(&fixture.db_path)
.expect("canonical fixture database path")
.to_string_lossy()
.into_owned();
assert_eq!(
session["suggested_command"]["argv"],
json!([
"cass",
"--db",
canonical_db_path,
"view",
source_path,
"--source",
"local",
"--conversation-id",
conversation_id.to_string(),
"--json"
])
);
assert!(
session["suggested_command"]["display"]
.as_str()
.is_some_and(|display| display.starts_with("cass --db ")
&& display.ends_with(&format!(
" --source local --conversation-id {conversation_id} --json"
)))
);
// Prove the returned argv remains exact even after a newer archive row is
// inserted with the same source/path identity.
let storage = coding_agent_search::storage::sqlite::FrankenStorage::open(&fixture.db_path)
.expect("reopen incident fixture");
let codex_id = storage
.ensure_agent(&sample_agent("codex", "Codex"))
.expect("resolve codex agent");
let mut newer = sample_conversation(
"codex",
&fixture.local_source_path,
&fixture.local_source_path,
"newer-same-path-session",
"Newer same-path fixture",
1_880_000_000_000,
vec![sample_message(
0,
MessageRole::User,
1_880_000_000_000,
"NEWER_SAME_PATH_ROW_MUST_NOT_BE_OPENED",
)],
);
newer.source_id = "local".to_string();
storage
.insert_conversation_tree(codex_id, None, &newer)
.expect("insert newer same-path row");
drop(storage);
let argv = session["suggested_command"]["argv"]
.as_array()
.expect("suggested argv")
.iter()
.map(|argument| argument.as_str().expect("string argv"))
.collect::<Vec<_>>();
let mut followup = base_cmd(tmp.path());
followup.args(&argv[1..]);
followup.timeout(Duration::from_secs(30));
let followup_output = followup.output().expect("execute suggested view argv");
assert!(
followup_output.status.success(),
"suggested argv failed: {}",
String::from_utf8_lossy(&followup_output.stderr)
);
let followup_json: Value =
serde_json::from_slice(&followup_output.stdout).expect("suggested view output JSON");
let followup_serialized = serde_json::to_string(&followup_json).unwrap();
assert!(followup_serialized.contains(fixture.secret_prompt_fragment));
assert!(!followup_serialized.contains("NEWER_SAME_PATH_ROW_MUST_NOT_BE_OPENED"));
let evidence = session["evidence_summaries"]
.as_array()
.expect("evidence_summaries array");
assert!(!evidence.is_empty());
for summary in evidence {
for fingerprint in summary["content_fingerprints"]
.as_array()
.expect("content_fingerprints array")
{
assert_eq!(fingerprint.as_str().map(str::len), Some(64));
}
for path in summary["evidence_paths"]
.as_array()
.expect("evidence_paths array")
{
assert_eq!(path, "incident session's evidence.jsonl");
}
}
assert_eq!(
json["data"]["redaction"]["private_text_policy"],
"suppress_all"
);
assert_eq!(json["data"]["redaction"]["hash_strategy"], "blake3_256_v1");
assert_eq!(json["data"]["redaction"]["opt_in_flags"], json!([]));
let serialized = serde_json::to_string(&json).unwrap();
assert!(!serialized.contains(fixture.secret_prompt_fragment));
assert!(!serialized.contains("private credential"));
assert!(!serialized.contains("raw_prompt_text\":"));
assert!(!serialized.contains("raw_tool_payload\":"));
}
#[test]
fn analytics_incidents_strict_session_cap_is_explicitly_partial() {
let tmp = TempDir::new().unwrap();
let fixture = seed_analytics_incidents_fixture(&tmp);
let (json, _) = run_analytics_incidents(
&tmp,
&[
"--limit",
"5",
"--max-sessions",
"1",
"--max-messages",
"100",
"--max-bytes",
"1048576",
"--budget-ms",
"60000",
"--json",
],
);
let discovery = &json["data"]["discovery"];
assert_eq!(json["data"]["count_scope"], "scanned_candidates_partial");
assert_eq!(discovery["partial"], true);
assert_eq!(discovery["timed_out"], false);
assert_eq!(discovery["stop_reason"], "files-capped");
assert_eq!(discovery["caps"]["max_files"], 1);
assert_eq!(discovery["files_scanned"], 1);
assert_eq!(discovery["files_considered"], 1);
assert_eq!(json["data"]["total_sessions"], 1);
assert_eq!(json["data"]["top_sessions_truncated"], false);
let top = &json["data"]["top_sessions"][0];
assert_eq!(top["session_id"], "remote-incident-session");
assert_eq!(top["agent"], "claude");
assert_eq!(top["host"], "ts1");
assert_eq!(top["source_id"], "remote-ci");
assert_eq!(
top["source_path"],
fixture.remote_source_path.to_string_lossy().to_string()
);
assert_eq!(top["exists_state"], "unknown");
}
#[test]
fn analytics_incidents_source_agent_filters_and_human_robot_output_are_in_parity() {
let tmp = TempDir::new().unwrap();
let _fixture = seed_analytics_incidents_fixture(&tmp);
let filter_args = [
"--agent",
"claude",
"--source",
"remote-ci",
"--limit",
"5",
"--max-sessions",
"10",
"--max-messages",
"100",
"--max-bytes",
"1048576",
"--budget-ms",
"60000",
];
let mut robot_args = filter_args.to_vec();
robot_args.push("--json");
let (mut robot, robot_stderr) = run_analytics_incidents(&tmp, &robot_args);
let (mut human, human_stderr) = run_analytics_incidents(&tmp, &filter_args);
assert!(robot_stderr.trim().is_empty());
assert!(
human_stderr.contains("analytics incidents"),
"human mode should identify the command: {human_stderr}"
);
assert_eq!(robot["data"]["total_sessions"], 1);
assert_eq!(robot["data"]["top_sessions"][0]["agent"], "claude");
assert_eq!(robot["data"]["top_sessions"][0]["source_id"], "remote-ci");
assert_eq!(robot["data"]["top_sessions"][0]["host"], "ts1");
assert!(
robot["_meta"]["filters_applied"]
.as_array()
.is_some_and(|filters| filters.contains(&json!("agent=claude")))
);
assert!(
robot["_meta"]["filters_applied"]
.as_array()
.is_some_and(|filters| filters.contains(&json!("source=remote-ci")))
);
normalize_incident_parity_fields(&mut robot);
normalize_incident_parity_fields(&mut human);
assert_eq!(
human, robot,
"human and robot modes must project the same data"
);
let (no_cross_source, _) = run_analytics_incidents(
&tmp,
&["--agent", "codex", "--source", "remote-ci", "--json"],
);
assert_eq!(no_cross_source["data"]["total_sessions"], 0);
assert_eq!(no_cross_source["data"]["total_hits"], 0);
assert_eq!(no_cross_source["data"]["top_sessions"], json!([]));
}
#[test]
fn analytics_incidents_rejects_zero_and_oversized_caps_before_opening_storage() {
let tmp = TempDir::new().unwrap();
for args in [
["--limit", "0"],
["--limit", "101"],
["--max-sessions", "0"],
["--max-sessions", "10001"],
["--max-messages", "0"],
["--max-bytes", "0"],
["--budget-ms", "0"],
] {
let mut cmd = base_cmd(tmp.path());
cmd.args(["analytics", "incidents", args[0], args[1], "--json"]);
let output = cmd.output().expect("run invalid analytics incidents cap");
assert_eq!(
output.status.code(),
Some(2),
"invalid cap should use the usage exit code: {args:?}"
);
assert!(
output.stdout.is_empty(),
"robot usage failures keep stdout data-only: {args:?}"
);
let stderr: Value = serde_json::from_slice(&output.stderr)
.unwrap_or_else(|error| panic!("invalid-cap stderr is not JSON for {args:?}: {error}"));
assert_eq!(stderr["error"]["kind"], "usage", "args={args:?}");
assert_eq!(stderr["error"]["retryable"], false, "args={args:?}");
}
}
#[test]
fn parse_analytics_models_subcommand_name_maps_to_variant() {
let cli = parse_cli_ok(
["cass", "analytics", "models", "--group-by", "day", "--json"],
"parse analytics models",
);
match cli.command {
Some(Commands::Analytics(AnalyticsCommand::AnalyticsModels { common, group_by })) => {
assert_eq!(group_by, AnalyticsBucketing::Day);
assert!(common.json);
}
other => panic!("expected analytics models command variant, got {other:?}"),
}
}
#[test]
fn analytics_group_by_invalid_value_returns_actionable_error() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--group-by", "fortnight", "--json"]);
let output = cmd.assert().failure().get_output().clone();
// Robot-mode parse failures are diagnostics and belong on stderr; stdout
// remains data-only.
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
assert!(
stderr.contains("possible values")
|| stderr.contains("possible value")
|| stderr.contains("invalid value"),
"invalid --group-by should report actionable enum guidance, stderr={stderr}"
);
}
// =============================================================================
// Analytics tokens data tests (br-z9fse.3.3)
// =============================================================================
#[test]
fn analytics_tokens_json_returns_buckets_and_totals() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert_eq!(json["command"], "analytics/tokens");
let data = &json["data"];
assert!(
data["buckets"].is_array(),
"analytics/tokens must expose buckets array: {data}"
);
assert!(
data["bucket_count"].is_number(),
"analytics/tokens must expose bucket_count: {data}"
);
// _meta must include path and group_by
let meta = &data["_meta"];
assert!(meta.is_object(), "missing _meta in data: {data}");
assert!(
meta["elapsed_ms"].is_number(),
"missing elapsed_ms in _meta: {meta}"
);
assert!(
meta["group_by"].is_string(),
"missing group_by in _meta: {meta}"
);
assert_eq!(meta["group_by"], "day", "default group_by should be day");
}
#[test]
fn analytics_tokens_group_by_hour() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--group-by", "hour", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
let meta = &json["data"]["_meta"];
assert_eq!(meta["group_by"], "hour");
assert_eq!(meta["source_table"], "usage_hourly");
}
#[test]
fn analytics_tokens_group_by_week() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--group-by", "week", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
let meta = &json["data"]["_meta"];
assert_eq!(meta["group_by"], "week");
assert_eq!(meta["source_table"], "usage_daily");
}
#[test]
fn analytics_tokens_group_by_month() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--group-by", "month", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
let meta = &json["data"]["_meta"];
assert_eq!(meta["group_by"], "month");
assert_eq!(meta["source_table"], "usage_daily");
}
#[test]
fn analytics_tokens_with_time_filter() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--days", "7", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
// Should still have valid structure even if no data in range
assert!(json["data"]["buckets"].is_array());
assert!(json["data"]["bucket_count"].is_number());
// Totals should always be present
let totals = &json["data"]["totals"];
assert!(
totals.is_object(),
"totals should be present even with empty results: {json}"
);
assert!(totals["counts"].is_object());
assert!(totals["api_tokens"].is_object());
assert!(totals["content_tokens"].is_object());
assert!(totals["coverage"].is_object());
assert!(totals["derived"].is_object());
}
#[test]
fn analytics_tokens_with_agent_filter() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--agent", "claude_code", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert!(json["data"]["buckets"].is_array());
// Verify filter was applied
let filters = json["_meta"]["filters_applied"]
.as_array()
.expect("filters_applied array");
let has_agent_filter = filters
.iter()
.any(|f| f.as_str().unwrap_or("").contains("agent="));
assert!(
has_agent_filter,
"should include agent filter in _meta.filters_applied"
);
}
#[test]
fn analytics_tokens_source_filter_matches_blank_remote_usage_rollups_via_origin_host() {
let tmp = TempDir::new().unwrap();
seed_analytics_remote_source_tokens_fixture(&tmp);
let mut cmd = base_cmd(tmp.path());
cmd.args(["analytics", "tokens", "--source", "remote-ci", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert_eq!(json["data"]["_meta"]["source_table"], "message_metrics");
assert_eq!(json["data"]["bucket_count"], 1);
assert_eq!(json["data"]["totals"]["counts"]["message_count"], 1);
assert_eq!(json["data"]["totals"]["counts"]["user_message_count"], 1);
let filters: Vec<String> = json["_meta"]["filters_applied"]
.as_array()
.expect("filters_applied array")
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect();
assert!(filters.iter().any(|value| value == "source=remote-ci"));
}
#[test]
fn analytics_tools_source_filter_matches_blank_remote_usage_rollups_via_origin_host() {
let tmp = TempDir::new().unwrap();
seed_analytics_remote_source_tools_fixture(&tmp);
let mut cmd = base_cmd(tmp.path());
cmd.args(["analytics", "tools", "--source", "remote-ci", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert_eq!(json["data"]["_meta"]["source_table"], "message_metrics");
assert_eq!(json["data"]["row_count"], 1);
assert_eq!(json["data"]["rows"][0]["key"], "codex");
assert_eq!(json["data"]["rows"][0]["tool_call_count"], 7);
assert_eq!(json["data"]["totals"]["tool_call_count"], 7);
assert_eq!(json["data"]["totals"]["message_count"], 1);
assert_eq!(json["data"]["totals"]["api_tokens_total"], 100);
let filters: Vec<String> = json["_meta"]["filters_applied"]
.as_array()
.expect("filters_applied array")
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect();
assert!(filters.iter().any(|value| value == "source=remote-ci"));
}
#[test]
fn analytics_tokens_workspace_filter_applies_and_normalizes_filters() {
let tmp = TempDir::new().unwrap();
let (workspace_a, _workspace_b) = seed_analytics_workspace_fixture(&tmp);
let workspace_arg = format!(" {} ", workspace_a.display());
let mut cmd = base_cmd(tmp.path());
cmd.args([
"analytics",
"tokens",
"--workspace",
&workspace_arg,
"--agent",
" codex ",
"--source",
" LOCAL ",
"--json",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert_eq!(json["data"]["totals"]["counts"]["message_count"], 2);
let filters: Vec<String> = json["_meta"]["filters_applied"]
.as_array()
.expect("filters_applied array")
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect();
assert!(filters.iter().any(|value| value == "agent=codex"));
assert!(filters.iter().any(|value| value == "source=local"));
assert!(
filters
.iter()
.any(|value| value == &format!("workspace={}", workspace_a.display()))
);
}
#[test]
fn analytics_status_workspace_filter_applies_and_normalizes_filters() {
let tmp = TempDir::new().unwrap();
let (workspace_a, _workspace_b) = seed_analytics_workspace_fixture(&tmp);
let workspace_arg = format!(" {} ", workspace_a.display());
let mut cmd = base_cmd(tmp.path());
cmd.args([
"analytics",
"status",
"--workspace",
&workspace_arg,
"--agent",
" codex ",
"--source",
" LOCAL ",
"--json",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!(
"invalid JSON: {e}
stdout={stdout}"
)
});
assert_eq!(json["data"]["coverage"]["total_messages"], 2);
let message_metrics = json["data"]["tables"]
.as_array()
.expect("tables array")
.iter()
.find(|table| table["table"] == "message_metrics")
.expect("message_metrics table entry");
assert_eq!(message_metrics["row_count"], 2);
let filters: Vec<String> = json["_meta"]["filters_applied"]
.as_array()
.expect("filters_applied array")
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect();
assert!(filters.iter().any(|value| value == "agent=codex"));
assert!(filters.iter().any(|value| value == "source=local"));
assert!(
filters
.iter()
.any(|value| value == &format!("workspace={}", workspace_a.display()))
);
}
#[test]
fn analytics_models_workspace_filter_applies_and_uses_workspace_scoped_breakdown() {
let tmp = TempDir::new().unwrap();
let workspace_a = seed_analytics_models_workspace_fixture(&tmp);
let workspace_arg = format!(" {} ", workspace_a.display());
let mut cmd = base_cmd(tmp.path());
cmd.args([
"analytics",
"models",
"--workspace",
&workspace_arg,
"--agent",
" codex ",
"--source",
" LOCAL ",
"--json",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert_eq!(
json["data"]["by_api_tokens"]["_meta"]["source_table"],
"token_usage"
);
let rows = json["data"]["by_api_tokens"]["rows"]
.as_array()
.expect("breakdown rows");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["key"], "gpt-4o");
assert_eq!(rows[0]["value"], 29);
let filters: Vec<String> = json["_meta"]["filters_applied"]
.as_array()
.expect("filters_applied array")
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect();
assert!(filters.iter().any(|value| value == "agent=codex"));
assert!(filters.iter().any(|value| value == "source=local"));
assert!(
filters
.iter()
.any(|value| value == &format!("workspace={}", workspace_a.display()))
);
}
#[test]
fn analytics_tokens_unknown_workspace_filter_returns_empty() {
let tmp = TempDir::new().unwrap();
let _ = seed_analytics_workspace_fixture(&tmp);
let missing_workspace = tmp.path().join("missing-workspace");
let mut cmd = base_cmd(tmp.path());
cmd.args([
"analytics",
"tokens",
"--workspace",
missing_workspace.to_string_lossy().as_ref(),
"--json",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert_eq!(json["data"]["bucket_count"], 0);
assert_eq!(json["data"]["totals"]["counts"]["message_count"], 0);
}
#[test]
fn analytics_tokens_totals_structure_complete() {
// Verify that the totals JSON includes all required sections
// even when the database has no data.
let mut cmd = simple_cmd();
cmd.args(["analytics", "tokens", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
let totals = &json["data"]["totals"];
if totals.is_object() {
// Check counts section
let counts = &totals["counts"];
assert!(counts["message_count"].is_number());
assert!(counts["user_message_count"].is_number());
assert!(counts["assistant_message_count"].is_number());
assert!(counts["tool_call_count"].is_number());
assert!(counts["plan_message_count"].is_number());
// Check api_tokens section
let api = &totals["api_tokens"];
assert!(api["total"].is_number());
assert!(api["input"].is_number());
assert!(api["output"].is_number());
assert!(api["cache_read"].is_number());
assert!(api["cache_creation"].is_number());
assert!(api["thinking"].is_number());
// Check content_tokens section
let content = &totals["content_tokens"];
assert!(content["est_total"].is_number());
assert!(content["est_user"].is_number());
assert!(content["est_assistant"].is_number());
// Check coverage section. api_coverage_pct is a metric-integrity
// value: a number when computable, else null paired with a
// machine-readable status kind explaining why.
let coverage = &totals["coverage"];
assert!(coverage["api_coverage_message_count"].is_number());
assert!(
coverage["api_coverage_pct"].is_number()
|| (coverage["api_coverage_pct"].is_null()
&& coverage["api_coverage_status"].is_string()),
"api_coverage_pct must be numeric or null+status: {coverage}"
);
// Check derived section exists
assert!(
totals["derived"].is_object(),
"totals.derived must be present"
);
}
}
// =============================================================================
// Analytics rebuild data tests (br-z9fse.3.4)
// =============================================================================
#[test]
fn analytics_rebuild_json_envelope_structure() {
// Use isolated temp dir to avoid DB lock contention with parallel tests.
let temp = TempDir::new().unwrap();
let mut cmd = base_cmd(temp.path());
cmd.args(["analytics", "rebuild", "--json"]);
let output = cmd.output().expect("run analytics rebuild");
if output.status.success() {
// DB existed and rebuild succeeded — validate JSON envelope on stdout.
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("invalid JSON: {e}\nstdout={stdout}"));
assert_eq!(json["command"], "analytics/rebuild");
assert!(
json["_meta"]["elapsed_ms"].is_number(),
"envelope must include _meta.elapsed_ms: {json}"
);
let data = &json["data"];
assert!(
data["track_a"].is_object(),
"analytics/rebuild must expose track_a results on success: {data}"
);
assert!(data["track_a"]["message_metrics_rows"].is_number());
assert!(data["track_a"]["usage_hourly_rows"].is_number());
assert!(data["track_a"]["usage_daily_rows"].is_number());
assert!(data["track_a"]["elapsed_ms"].is_number());
assert_eq!(data["track"], "a");
assert!(data["tracks_rebuilt"].is_array());
assert!(data["overall_elapsed_ms"].is_number());
} else {
// Robot-mode fatal diagnostics are emitted on stderr so stdout remains
// data-only.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.trim().is_empty(),
"analytics rebuild should emit an error envelope on stderr when DB is missing"
);
// The envelope should mention the missing database.
assert!(
stderr.contains("not found") || stderr.contains("missing") || stderr.contains("error"),
"rebuild error should describe the missing DB: {stderr}"
);
}
}
#[test]
fn analytics_validate_reports_query_failure_for_malformed_schema() {
let tmp_home = TempDir::new().expect("temp home");
let data_dir = tmp_home.path().join("cass_data");
fs::create_dir_all(&data_dir).expect("create data dir");
let db_path = data_dir.join("agent_search.db");
let conn =
FrankenConnection::open(db_path.to_string_lossy().to_string()).expect("create sqlite db");
conn.execute_batch(
"CREATE TABLE message_metrics (day_id INTEGER);
CREATE TABLE usage_daily (day_id INTEGER);
INSERT INTO usage_daily (day_id) VALUES (20254);",
)
.expect("create malformed analytics tables");
drop(conn);
let mut cmd = base_cmd(tmp_home.path());
cmd.args([
"analytics",
"validate",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid analytics validate JSON");
assert_eq!(json["command"], "analytics/validate");
let checks = json["data"]["checks"].as_array().expect("checks array");
let query_failure = checks
.iter()
.find(|check| check["id"] == "track_a.query_exec")
.expect("track_a query failure should be reported");
assert_eq!(query_failure["ok"], false);
assert_eq!(query_failure["severity"], "error");
assert!(
query_failure["details"]
.as_str()
.unwrap()
.contains("Track A invariant query failed")
);
assert_eq!(json["data"]["perf"]["timeseries"]["within_budget"], false);
assert!(
json["data"]["perf"]["timeseries"]["error"]
.as_str()
.is_some_and(|error| !error.trim().is_empty())
);
assert!(
json["data"]["perf"]["timeseries"]["details"]
.as_str()
.unwrap()
.contains("failed")
);
assert_eq!(json["data"]["perf"]["breakdown"]["within_budget"], false);
assert!(
json["data"]["perf"]["breakdown"]["error"]
.as_str()
.is_some_and(|error| !error.trim().is_empty())
);
assert!(
json["data"]["summary"]["errors"].as_u64().unwrap_or(0) >= 1,
"malformed analytics schema should surface at least one error"
);
}
#[test]
fn analytics_validate_fix_noops_when_reports_are_clean() {
let tmp_home = TempDir::new().expect("temp home");
let _workspace = seed_analytics_models_workspace_fixture(&tmp_home);
let data_dir = tmp_home.path().join(".local/share/coding-agent-search");
let mut cmd = base_cmd(tmp_home.path());
cmd.args([
"analytics",
"validate",
"--fix",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid analytics validate JSON");
assert_eq!(json["command"], "analytics/validate");
assert_eq!(
json["data"]["summary"]["errors"], 0,
"clean analytics validate --fix should finish without remaining errors: {json}"
);
assert_eq!(json["data"]["fix"]["requested"], true);
assert_eq!(json["data"]["fix"]["changed"], false);
assert_eq!(
json["data"]["fix"]["applied_repairs"]
.as_array()
.expect("applied repairs array")
.len(),
0
);
assert_eq!(
json["data"]["fix"]["skipped_repairs"]
.as_array()
.expect("skipped repairs array")
.len(),
0
);
}
#[test]
fn analytics_validate_fix_rebuilds_track_a_when_safe() {
let tmp_home = TempDir::new().expect("temp home");
let _workspace = seed_analytics_models_workspace_fixture(&tmp_home);
let data_dir = tmp_home.path().join(".local/share/coding-agent-search");
let db_path = data_dir.join("agent_search.db");
let conn =
FrankenConnection::open(db_path.to_string_lossy().to_string()).expect("open analytics db");
conn.execute("UPDATE usage_daily SET message_count = message_count + 7")
.expect("corrupt track a rollup");
drop(conn);
let mut cmd = base_cmd(tmp_home.path());
cmd.args([
"analytics",
"validate",
"--fix",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid analytics validate JSON");
let applied = json["data"]["fix"]["applied_repairs"]
.as_array()
.expect("applied repairs array");
assert!(
applied
.iter()
.any(|repair| repair["kind"] == "rebuild_track_a"),
"track a corruption should trigger an automatic Track A rebuild: {json}"
);
assert_eq!(
json["data"]["summary"]["errors"], 0,
"safe Track A repair should clear remaining errors: {json}"
);
let checks = json["data"]["checks"].as_array().expect("checks array");
let message_count_check = checks
.iter()
.find(|check| check["id"] == "track_a.message_count_match")
.expect("track_a.message_count_match check");
assert_eq!(message_count_check["ok"], true);
}
#[test]
fn analytics_validate_fix_refuses_when_source_schema_is_missing() {
let tmp_home = TempDir::new().expect("temp home");
let data_dir = tmp_home.path().join("cass_data");
fs::create_dir_all(&data_dir).expect("create data dir");
let db_path = data_dir.join("agent_search.db");
let conn =
FrankenConnection::open(db_path.to_string_lossy().to_string()).expect("create sqlite db");
conn.execute_batch(
"CREATE TABLE message_metrics (day_id INTEGER);
CREATE TABLE usage_daily (day_id INTEGER);
INSERT INTO usage_daily (day_id) VALUES (20254);",
)
.expect("create malformed analytics tables");
drop(conn);
let mut cmd = base_cmd(tmp_home.path());
cmd.args([
"analytics",
"validate",
"--fix",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid analytics validate JSON");
let applied = json["data"]["fix"]["applied_repairs"]
.as_array()
.expect("applied repairs array");
let skipped = json["data"]["fix"]["skipped_repairs"]
.as_array()
.expect("skipped repairs array");
assert!(
applied.is_empty(),
"unsafe malformed schemas must not be mutated"
);
assert!(
skipped.iter().any(|repair| {
repair["kind"] == "rebuild_track_a"
&& repair["reason"]
.as_str()
.is_some_and(|reason| reason.contains("raw cass source tables"))
}),
"missing raw schema should be reported as a skipped repair: {json}"
);
assert!(
json["data"]["summary"]["errors"].as_u64().unwrap_or(0) >= 1,
"malformed analytics schema should still report an error after refusing repair"
);
}
#[test]
fn analytics_rebuild_help_shows_force_flag() {
let mut cmd = simple_cmd();
cmd.args(["analytics", "rebuild", "--help"]);
cmd.assert()
.success()
.stdout(contains("--force"))
.stdout(contains("--json"));
}
#[test]
fn analytics_rebuild_parses_force_and_json_flags() {
let cli = parse_cli_ok(
["cass", "analytics", "rebuild", "--force", "--json"],
"parse analytics rebuild with force+json",
);
match cli.command {
Some(Commands::Analytics(AnalyticsCommand::Rebuild {
common,
force,
track,
})) => {
assert!(force, "--force should be true");
assert!(common.json, "--json should be true");
assert_eq!(
track,
coding_agent_search::AnalyticsTrack::A,
"--track should default to Track A"
);
}
other => panic!("expected analytics rebuild, got {other:?}"),
}
}
/// #397: `analytics validate` recommends `--track all`; the flag must parse
/// for every documented value.
#[test]
fn analytics_rebuild_parses_track_flag() {
for (value, expected) in [
("a", coding_agent_search::AnalyticsTrack::A),
("b", coding_agent_search::AnalyticsTrack::B),
("all", coding_agent_search::AnalyticsTrack::All),
] {
let cli = parse_cli_ok(
["cass", "analytics", "rebuild", "--track", value, "--json"],
"parse analytics rebuild with --track",
);
match cli.command {
Some(Commands::Analytics(AnalyticsCommand::Rebuild { track, .. })) => {
assert_eq!(track, expected, "--track {value} should parse");
}
other => panic!("expected analytics rebuild, got {other:?}"),
}
}
}