mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
77a3b8e379
Finish the canonical follow-up fix on top ofaa5d506and1f37b65: - 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.
4564 lines
153 KiB
Rust
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(¤t_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:?}"),
|
|
}
|
|
}
|
|
}
|