Files
Dicklesworthstone e1ad357558 feat(robot): additive budget envelope metadata for robot payloads
Adds a compatible way to attach budget accounting to robot payloads whose wire
contract cannot adopt the full `BudgetEnvelope` without reshaping.

- robot_budget_envelope: additive budget metadata embedded under a top-level
  `budget` key; every field is optional so existing consumers ignore it while
  budget-aware clients read token/row/time accounting.
- pack_planner and the search perf bench account against it; cli_robot,
  cli_status_budget, cli_view_budget, robot_perf, and the search-format contract
  spec updated to assert the envelope shape.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 13:44:38 -04:00

8153 lines
267 KiB
Rust

use assert_cmd::Command;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use fs2::FileExt;
use predicates::prelude::*;
use predicates::str::contains;
use serde_json::Value;
use std::collections::HashSet;
use std::error::Error;
use std::fs;
use std::io::ErrorKind;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use tempfile::TempDir;
use walkdir::WalkDir;
use clap::{self, CommandFactory, Parser};
use coding_agent_search::{Cli, Commands};
mod util;
use util::cass_bin;
fn base_cmd() -> Command {
let mut cmd = Command::new(cass_bin());
cmd.env("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT", "1");
cmd
}
fn run_on_large_stack<F>(f: F)
where
F: FnOnce() + Send + 'static,
{
let handle = std::thread::Builder::new()
.name("cass-cli-robot-clap-test".to_string())
.stack_size(16 * 1024 * 1024)
.spawn(f)
.expect("spawn large-stack test thread");
match handle.join() {
Ok(()) => {}
Err(panic) => std::panic::resume_unwind(panic),
}
}
const SEARCH_DEMO_DATA_DIR: &str = "tests/fixtures/search_demo_data";
fn is_transient_lexical_build_path(path: &Path) -> bool {
path.components().any(|component| {
component
.as_os_str()
.to_str()
.is_some_and(|name| name.starts_with("cass-lexical-shards."))
})
}
fn safe_fixture_destination(dst_root: &Path, rel: &Path) -> Result<PathBuf, Box<dyn Error>> {
let mut dst = dst_root.to_path_buf();
for component in rel.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => dst.push(part),
_ => {
return Err(Box::new(std::io::Error::new(
ErrorKind::InvalidInput,
"fixture path escaped source root",
)));
}
}
}
Ok(dst)
}
fn isolated_search_demo_data() -> Result<TempDir, Box<dyn Error>> {
let tmp = TempDir::new()?;
let src = Path::new(SEARCH_DEMO_DATA_DIR);
for entry in WalkDir::new(src) {
let entry = match entry {
Ok(entry) => entry,
Err(err)
if err
.io_error()
.is_some_and(|io| io.kind() == ErrorKind::NotFound)
&& err.path().is_some_and(is_transient_lexical_build_path) =>
{
continue;
}
Err(err) => return Err(Box::new(err)),
};
let rel = entry.path().strip_prefix(src)?;
let dst = safe_fixture_destination(tmp.path(), rel)?;
if entry.file_type().is_dir() {
fs::create_dir_all(&dst)?;
} else {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if let Err(err) = fs::copy(entry.path(), &dst) {
if err.kind() == ErrorKind::NotFound
&& is_transient_lexical_build_path(entry.path())
{
continue;
}
return Err(Box::new(std::io::Error::new(
err.kind(),
format!(
"failed to copy search demo fixture {} to {}: {err}",
entry.path().display(),
dst.display()
),
)));
}
}
}
Ok(tmp)
}
fn isolated_search_demo_data_for_current_workspace() -> Result<TempDir, Box<dyn Error>> {
use frankensqlite::compat::ConnectionExt;
let tmp = isolated_search_demo_data()?;
let current_workspace = std::env::current_dir()?.to_string_lossy().into_owned();
let db_path = tmp.path().join("agent_search.db");
let conn = frankensqlite::Connection::open(db_path.to_string_lossy().into_owned())?;
conn.execute_compat(
"UPDATE workspaces SET path = ?1 WHERE id = 1",
frankensqlite::params![current_workspace],
)?;
Ok(tmp)
}
fn decoded_cursor_offset(cursor: &str) -> u64 {
let decoded = BASE64_STANDARD
.decode(cursor)
.expect("cursor should be valid base64");
let payload: Value = serde_json::from_slice(&decoded).expect("cursor should decode as json");
payload["offset"]
.as_u64()
.expect("cursor should include numeric offset")
}
fn recommended_command<'a>(json: &'a Value, id: &str) -> &'a Value {
json["recommended_commands"]
.as_array()
.and_then(|commands| {
commands
.iter()
.find(|command| command["id"].as_str() == Some(id))
})
.unwrap_or_else(|| panic!("missing recommended command {id}: {json}"))
}
fn assert_not_initialized_recommended_commands(json: &Value, data_dir: &Path) {
let data_dir_text = data_dir.display().to_string();
let initialize = recommended_command(json, "initialize-archive");
let initialize_command = initialize["command"]
.as_str()
.expect("initialize command should be a string");
assert!(
initialize_command.starts_with("cass index --full --json --no-progress-events --data-dir "),
"fresh installs should expose an exact initial indexing command: {initialize_command}"
);
assert!(
initialize_command.contains(&data_dir_text),
"initial indexing command should target the probed data_dir {data_dir_text}: {initialize_command}"
);
assert_eq!(
initialize["safety"],
Value::String("writes-cass-archive-and-derived-index".to_string())
);
assert!(
initialize["parse_fields"]
.as_array()
.is_some_and(|fields| fields.iter().any(|field| field.as_str() == Some("success"))),
"command should tell agents which fields to parse: {initialize}"
);
let verify = recommended_command(json, "verify-initialization");
let verify_command = verify["command"]
.as_str()
.expect("verify command should be a string");
assert!(
verify_command.starts_with("cass health --json --data-dir "),
"fresh installs should expose a targeted verification command: {verify_command}"
);
assert!(
verify_command.contains(&data_dir_text),
"verification command should target the probed data_dir {data_dir_text}: {verify_command}"
);
assert!(
verify["parse_fields"].as_array().is_some_and(|fields| {
fields
.iter()
.any(|field| field.as_str() == Some("recommended_commands"))
}),
"verification command should keep agents in the next-command loop: {verify}"
);
}
fn hold_active_lexical_rebuild_lock(
data_dir: &Path,
db_path: &Path,
completed: bool,
runtime: Option<Value>,
) -> fs::File {
let index_path = coding_agent_search::search::tantivy::expected_index_dir(data_dir);
fs::create_dir_all(&index_path).expect("create index dir");
let (
total_conversations,
total_messages,
storage_fingerprint,
committed_offset,
committed_conversation_id,
processed_conversations,
indexed_docs,
) = if completed {
(2, 6, "content-v1:2:2:6", 2, 2, 2, 6)
} else {
(10, 20, "10:20:0:0", 4, 4, 4, 8)
};
let mut rebuild_state = serde_json::json!({
"version": 2,
"schema_hash": coding_agent_search::search::tantivy::SCHEMA_HASH,
"db": {
"db_path": db_path.display().to_string(),
"total_conversations": total_conversations,
"total_messages": total_messages,
"storage_fingerprint": storage_fingerprint
},
"page_size": 1024,
"committed_offset": committed_offset,
"committed_conversation_id": committed_conversation_id,
"processed_conversations": processed_conversations,
"indexed_docs": indexed_docs,
"committed_meta_fingerprint": null,
"pending": null,
"completed": completed,
"updated_at_ms": 1_733_000_123_000_i64
});
if let Some(runtime) = runtime {
rebuild_state["runtime"] = runtime;
}
fs::write(
index_path.join(".lexical-rebuild-state.json"),
serde_json::to_vec_pretty(&rebuild_state).expect("serialize rebuild state"),
)
.expect("write rebuild state");
let lock_path = data_dir.join("index-run.lock");
let mut lock_file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.read(true)
.write(true)
.open(&lock_path)
.expect("open lock file");
lock_file.try_lock_exclusive().expect("hold index lock");
writeln!(
lock_file,
"pid={}\nstarted_at_ms={}\ndb_path={}\nmode=index\njob_kind=lexical_refresh\nphase=rebuilding",
std::process::id(),
1_733_001_444_000_i64,
db_path.display()
)
.expect("write lock metadata");
lock_file.flush().expect("flush lock metadata");
lock_file
}
#[test]
fn robot_help_prints_contract() {
let mut cmd = base_cmd();
cmd.arg("--robot-help");
cmd.assert()
.success()
.stdout(contains("cass --robot-help (contract v1)"))
.stdout(contains("Exit codes: 0 ok"));
}
#[test]
fn robot_help_has_sections_and_no_ansi() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "--robot-help"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-help should not emit ANSI when color=never"
);
for needle in &[
"QUICKSTART",
"TIME FILTERS:",
"WORKFLOW:",
"OUTPUT:",
"Core subcommands:",
"Exit codes:",
] {
assert!(
stdout.contains(needle),
"robot-help output missing section {needle}"
);
}
}
#[test]
fn api_version_reports_contract() {
let mut cmd = base_cmd();
cmd.args(["api-version", "--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 api-version json");
assert_eq!(json["api_version"], 1);
assert_eq!(json["contract_version"], "1");
assert!(
json["crate_version"].is_string(),
"crate_version should be a string, got: {:?}",
json["crate_version"]
);
}
#[test]
fn capabilities_are_self_describing_for_agents() {
let mut cmd = base_cmd();
cmd.args(["capabilities", "--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 capabilities json");
assert_eq!(json["version"], json["crate_version"]);
assert_eq!(json["api_version"], 1);
assert_eq!(json["contract_version"], "1");
let features = json["features"].as_array().expect("features array");
assert!(
features
.iter()
.any(|feature| feature == "self_describing_capabilities"),
"capabilities should advertise the richer first-stop agent contract"
);
let globals = json["global_flags"].as_array().expect("global flags array");
assert!(
globals.iter().any(|flag| flag["name"] == "robot-help"),
"capabilities should include global robot-help flag"
);
assert!(
globals.iter().any(|flag| flag["name"] == "color"
&& flag["value_type"] == "enum"
&& flag["default"] == "auto"),
"capabilities should include typed global flags"
);
let commands = json["commands"].as_array().expect("commands array");
for expected in [
"triage",
"search",
"pack",
"health",
"introspect",
"robot-docs",
] {
assert!(
commands.iter().any(|command| command["name"] == expected),
"capabilities should include command {expected}"
);
}
let search = commands
.iter()
.find(|command| command["name"] == "search")
.expect("search command present");
let search_args = search["arguments"].as_array().expect("search args array");
assert!(
search_args.iter().any(|arg| arg["name"] == "query"
&& arg["arg_type"] == "positional"
&& arg["required"] == true),
"capabilities should expose search positional query argument"
);
let exit_codes = json["exit_codes"].as_array().expect("exit_codes array");
assert!(
exit_codes.iter().any(|code| code["code"] == "2"
&& code["retryable"] == "no"
&& code["agent_action"]
.as_str()
.unwrap_or_default()
.contains("Fix argv")),
"capabilities should include actionable usage-error handling"
);
assert!(
exit_codes.iter().any(|code| code["code"] == "15"
&& code["meaning"]
.as_str()
.unwrap_or_default()
.contains("semantic")),
"capabilities should include semantic fallback exit guidance"
);
let env_vars = json["env_vars"].as_array().expect("env_vars array");
for expected in ["CASS_DATA_DIR", "CASS_OUTPUT_FORMAT", "CASS_TRACE_FILE"] {
assert!(
env_vars.iter().any(|env_var| env_var["name"] == expected),
"capabilities should include env var {expected}"
);
}
let workflows = json["workflows"].as_array().expect("workflows array");
let cold_start = workflows
.iter()
.find(|workflow| workflow["name"] == "cold-start")
.expect("cold-start workflow present");
assert_eq!(
cold_start["first_command"],
Value::String("cass triage --json".to_string()),
"cold-start should make triage the first instinctive command"
);
let bounded_search = workflows
.iter()
.find(|workflow| workflow["name"] == "bounded-search")
.expect("bounded-search workflow present");
assert!(
bounded_search["first_command"]
.as_str()
.unwrap_or_default()
.contains("--limit 10"),
"bounded-search workflow should teach explicit robot limits"
);
assert!(
bounded_search["follow_up_commands"]
.as_array()
.expect("follow-up commands array")
.iter()
.any(|command| command
.as_str()
.unwrap_or_default()
.starts_with("cass view ")),
"bounded-search workflow should include a hit drill-down command"
);
let recoveries = json["mistake_recoveries"]
.as_array()
.expect("mistake_recoveries array");
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
.as_str()
.unwrap_or_default()
.contains("searh")
&& recovery["canonical"]
.as_str()
.unwrap_or_default()
.contains("search")
&& recovery["accepted"] == true),
"capabilities should advertise top-level typo recovery"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass ready --json"
&& recovery["canonical"] == "cass triage --json"
&& recovery["accepted"] == true),
"capabilities should advertise ready as a triage alias"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass --json"
&& recovery["canonical"] == "cass triage --json"
&& recovery["accepted"] == true),
"capabilities should advertise root --json as a safe triage default"
);
assert!(
recoveries.iter().any(
|recovery| recovery["wrong"] == "cass search query=auth --json"
&& recovery["canonical"] == "cass search auth --json"
&& recovery["accepted"] == true
),
"capabilities should advertise query assignment recovery"
);
assert!(
recoveries.iter().any(
|recovery| recovery["wrong"] == "cass search --q auth --json"
&& recovery["canonical"] == "cass search auth --json"
&& recovery["accepted"] == true
),
"capabilities should advertise query alias flag recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass html-export session.jsonl --json"
&& recovery["canonical"] == "cass export-html session.jsonl --json"
&& recovery["accepted"] == true),
"capabilities should advertise reversed HTML export alias recovery"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass commands --json"
&& recovery["canonical"] == "cass robot-docs commands"
&& recovery["accepted"] == true),
"capabilities should advertise robot-docs topic shorthand recovery"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass help --json"
&& recovery["canonical"] == "cass robot-docs guide"
&& recovery["accepted"] == true),
"capabilities should advertise structured help recovery"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass search --help --json"
&& recovery["canonical"] == "cass robot-docs commands"
&& recovery["accepted"] == true),
"capabilities should advertise structured command help recovery"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass current --json"
&& recovery["canonical"] == "cass sessions --current --json"
&& recovery["accepted"] == true),
"capabilities should advertise current-session shorthand recovery"
);
assert!(
recoveries.iter().any(
|recovery| recovery["wrong"] == "cass sessions current --json"
&& recovery["canonical"] == "cass sessions --current --json"
&& recovery["accepted"] == true
),
"capabilities should advertise sessions current shorthand recovery"
);
assert!(
recoveries.iter().any(
|recovery| recovery["wrong"] == "cass search auth error --json"
&& recovery["canonical"] == "cass search \"auth error\" --json"
&& recovery["accepted"] == true
),
"capabilities should advertise multi-word search query recovery"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass answer auth --json"
&& recovery["canonical"] == "cass pack auth --json"
&& recovery["accepted"] == true),
"capabilities should advertise answer-pack alias recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass why auth failed --json --max-evidence 3"
&& recovery["canonical"] == "cass pack \"auth failed\" --json --max-evidence 3"
&& recovery["accepted"] == true),
"capabilities should advertise question-pack alias recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass auth failed --json --max-evidence 3"
&& recovery["canonical"] == "cass pack \"auth failed\" --json --max-evidence 3"
&& recovery["accepted"] == true),
"capabilities should advertise pack-only flag intent recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth failed --json --max-evidence 3"
&& recovery["canonical"] == "cass pack \"auth failed\" --json --max-evidence 3"
&& recovery["accepted"] == true),
"capabilities should advertise explicit search pack-intent recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth provider codex limit 5 last 7d --json"
&& recovery["canonical"]
== "cass search auth --agent codex --limit 5 --since -7d --json"
&& recovery["accepted"] == true),
"capabilities should advertise bare filter pair recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search --agent codex --limit 5 auth error --json"
&& recovery["canonical"]
== "cass search \"auth error\" --agent codex --limit 5 --json"
&& recovery["accepted"] == true),
"capabilities should advertise leading-filter query recovery"
);
assert!(
recoveries
.iter()
.any(|recovery| recovery["wrong"] == "cass auth error --json"
&& recovery["canonical"] == "cass search \"auth error\" --json"
&& recovery["accepted"] == true),
"capabilities should advertise implicit robot search recovery"
);
assert!(
recoveries.iter().any(
|recovery| recovery["wrong"] == "cass view session.jsonl:42 --json"
&& recovery["canonical"] == "cass view session.jsonl --line 42 --json"
&& recovery["accepted"] == true
),
"capabilities should advertise path:line drill-down recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass view session.jsonl line=42 --json"
&& recovery["canonical"] == "cass view session.jsonl --line 42 --json"
&& recovery["accepted"] == true),
"capabilities should advertise drill-down assignment recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass view session.jsonl --line-number 42 --json"
&& recovery["canonical"] == "cass view session.jsonl --line 42 --json"
&& recovery["accepted"] == true),
"capabilities should advertise line_number drill-down alias recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass view source_path=session.jsonl source_id=local line_number=42 --json"
&& recovery["canonical"] == "cass view session.jsonl --source local --line 42 --json"
&& recovery["accepted"] == true),
"capabilities should advertise search-hit field bundle recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth --max_results 5 --json"
&& recovery["canonical"] == "cass search auth --limit 5 --json"
&& recovery["accepted"] == true),
"capabilities should advertise snake-case long flag recovery"
);
assert!(
recoveries.iter().any(
|recovery| recovery["wrong"] == "cass search auth --output json"
&& recovery["canonical"] == "cass search auth --robot-format json"
&& recovery["accepted"] == true
),
"capabilities should advertise output format alias recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth max_results=5 --json"
&& recovery["canonical"] == "cass search auth --limit 5 --json"
&& recovery["accepted"] == true),
"capabilities should advertise result-count assignment recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth agent=codex days=7 fields=minimal --json"
&& recovery["canonical"]
== "cass search auth --agent codex --days 7 --fields minimal --json"
&& recovery["accepted"] == true),
"capabilities should advertise search filter assignment recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth last=7d before=now --json"
&& recovery["canonical"] == "cass search auth --since -7d --until now --json"
&& recovery["accepted"] == true),
"capabilities should advertise time-window assignment recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth --last 7 --before now --json"
&& recovery["canonical"] == "cass search auth --since -7d --until now --json"
&& recovery["accepted"] == true),
"capabilities should advertise time-window alias flag recovery"
);
assert!(
recoveries.iter().any(|recovery| recovery["wrong"]
== "cass search auth --provider codex --json"
&& recovery["canonical"] == "cass search auth --agent codex --json"
&& recovery["accepted"] == true),
"capabilities should advertise provider alias recovery"
);
}
#[test]
fn triage_missing_db_is_success_and_actionable() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_string_lossy().to_string();
let mut cmd = base_cmd();
cmd.args(["triage", "--json", "--data-dir", &data_dir]);
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 triage json");
assert_eq!(json["surface"], "triage");
assert_eq!(json["schema_version"], 1);
assert_eq!(json["status"], "not_initialized");
assert_eq!(json["healthy"], false);
assert_eq!(json["initialized"], false);
assert!(
json["next_command"]
.as_str()
.unwrap_or_default()
.starts_with("cass index --full --json --no-progress-events --data-dir "),
"triage should expose the exact next command: {json}"
);
assert_not_initialized_recommended_commands(&json, tmp.path());
assert_eq!(
json["discovery"]["capabilities_command"],
Value::String("cass capabilities --json".to_string())
);
assert_eq!(
json["discovery"]["schemas_command"],
Value::String("cass introspect --json".to_string())
);
assert!(
json["starter_workflows"]
.as_array()
.is_some_and(|workflows| {
workflows
.iter()
.any(|workflow| workflow["name"] == "cold-start")
}),
"triage should inline starter workflows for zero-context callers: {json}"
);
assert!(
json["mistake_recoveries"]
.as_array()
.is_some_and(|recoveries| {
recoveries
.iter()
.any(|recovery| recovery["canonical"] == "cass triage --json")
}),
"triage should inline accepted recovery aliases: {json}"
);
assert_eq!(json["readiness"]["index"]["exists"], false);
}
#[test]
fn triage_aliases_are_accepted() {
for alias in ["ready", "preflight"] {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_string_lossy().to_string();
let mut cmd = base_cmd();
cmd.args([alias, "--json", "--data-dir", &data_dir]);
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("alias should return valid triage json");
assert_eq!(json["surface"], "triage", "alias {alias} should run triage");
assert_eq!(json["status"], "not_initialized");
}
}
#[test]
fn root_json_defaults_to_triage() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_string_lossy().to_string();
let mut cmd = base_cmd();
cmd.args(["--json", "--data-dir", &data_dir]);
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 root triage json");
assert_eq!(json["surface"], "triage");
assert_eq!(json["status"], "not_initialized");
assert_not_initialized_recommended_commands(&json, tmp.path());
}
#[test]
fn root_robot_defaults_to_triage() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_string_lossy().to_string();
let mut cmd = base_cmd();
cmd.env("CASS_DATA_DIR", &data_dir);
cmd.arg("--robot");
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 root robot triage json");
assert_eq!(json["surface"], "triage");
assert_eq!(json["status"], "not_initialized");
assert_not_initialized_recommended_commands(&json, tmp.path());
}
#[test]
fn root_robot_format_defaults_to_triage() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_string_lossy().to_string();
let mut cmd = base_cmd();
cmd.env("CASS_DATA_DIR", &data_dir);
cmd.args(["--robot-format", "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 root robot-format triage json");
assert_eq!(json["surface"], "triage");
assert_eq!(json["status"], "not_initialized");
assert_not_initialized_recommended_commands(&json, tmp.path());
}
#[test]
fn leading_json_before_search_attaches_to_search() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"--json",
"search",
"foo",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
assert_eq!(output.status.code(), Some(3));
assert!(
output.stdout.is_empty(),
"search errors should stay on stderr in robot mode"
);
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain JSON error");
let json: Value = serde_json::from_str(last_line).expect("valid JSON error");
assert_eq!(json["error"]["kind"], "missing-index");
}
#[test]
fn leading_robot_before_search_attaches_to_search() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"--robot",
"search",
"foo",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain JSON error");
let json: Value = serde_json::from_str(last_line).expect("valid JSON error");
assert_eq!(json["error"]["kind"], "missing-index");
}
#[test]
fn leading_robot_before_status_attaches_to_status() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"--robot",
"status",
"--data-dir",
tmp.path().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 status json");
assert_eq!(json["status"], "not_initialized");
assert_eq!(json["initialized"], false);
assert_eq!(json["surface"], Value::Null);
assert_not_initialized_recommended_commands(&json, tmp.path());
}
#[test]
fn leading_json_before_capabilities_attaches_to_capabilities() {
let mut cmd = base_cmd();
cmd.args(["--json", "capabilities"]);
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 capabilities json");
assert_eq!(json["contract_version"], "1");
assert!(json["commands"].as_array().is_some_and(|commands| {
commands
.iter()
.any(|command| command["name"] == "capabilities")
}));
}
#[test]
fn leading_json_before_search_deduplicates_existing_json_flag() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"--json",
"search",
"foo",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain JSON error");
let json: Value = serde_json::from_str(last_line).expect("valid JSON error");
assert_eq!(json["error"]["kind"], "missing-index");
}
#[test]
fn search_named_query_flag_attaches_to_query_positional() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"--query",
"foo",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain JSON error");
let json: Value = serde_json::from_str(last_line).expect("valid JSON error");
assert_eq!(json["error"]["kind"], "missing-index");
}
#[test]
fn search_named_query_alias_flags_attach_to_query_positional() {
for (flag, query) in [
("--q", "quick auth sentinel"),
("--text", "text auth sentinel"),
("--pattern", "pattern auth sentinel"),
] {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
flag,
query,
"--json",
"--dry-run",
"--data-dir",
tmp.path().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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true), "flag {flag}");
assert_eq!(json["valid"].as_bool(), Some(true), "flag {flag}");
assert_eq!(json["query"].as_str(), Some(query), "flag {flag}");
}
}
#[test]
fn search_query_assignment_attaches_to_query_positional() {
for assignment in [
"query=dry run sentinel",
"q=short dry run sentinel",
"text=text dry run sentinel",
"pattern=pattern dry run sentinel",
] {
let expected = assignment
.split_once('=')
.map(|(_, value)| value)
.expect("assignment value");
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
assignment,
"--json",
"--dry-run",
"--data-dir",
tmp.path().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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true), "{assignment}");
assert_eq!(json["valid"].as_bool(), Some(true), "{assignment}");
assert_eq!(json["query"].as_str(), Some(expected), "{assignment}");
}
}
#[test]
fn html_export_aliases_route_to_export_html_command() {
for alias in ["html-export", "html_export", "exporthtml"] {
let mut cmd = base_cmd();
cmd.args([
alias,
"tests/fixtures/html_export/edge_cases/single_message.jsonl",
"--json",
"--dry-run",
]);
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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true), "{alias}");
assert_eq!(json["valid"].as_bool(), Some(true), "{alias}");
assert_eq!(
json["session_path"].as_str(),
Some("tests/fixtures/html_export/edge_cases/single_message.jsonl"),
"{alias}"
);
assert_eq!(json["messages"].as_u64(), Some(1), "{alias}");
}
}
#[test]
fn current_session_aliases_route_to_sessions_current() -> Result<(), Box<dyn Error>> {
let data_dir = isolated_search_demo_data_for_current_workspace()?;
let data_dir_arg = data_dir.path().to_string_lossy().into_owned();
let expected_workspace = std::env::current_dir()?.to_string_lossy().into_owned();
for args in [
vec!["current", "--json", "--data-dir", data_dir_arg.as_str()],
vec![
"current-session",
"--json",
"--data-dir",
data_dir_arg.as_str(),
],
vec![
"current_session",
"--json",
"--data-dir",
data_dir_arg.as_str(),
],
vec![
"sessions",
"current",
"--json",
"--data-dir",
data_dir_arg.as_str(),
],
vec![
"session",
"current",
"--json",
"--data-dir",
data_dir_arg.as_str(),
],
vec!["--json", "current", "--data-dir", data_dir_arg.as_str()],
] {
let mut cmd = base_cmd();
cmd.args(args);
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 sessions JSON");
let sessions = json["sessions"].as_array().expect("sessions array");
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["agent"].as_str(), Some("aider"));
assert_eq!(
sessions[0]["workspace"].as_str(),
Some(expected_workspace.as_str())
);
}
Ok(())
}
#[test]
fn leading_json_search_named_query_flag_combines_recoveries() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"--json",
"search",
"--query=foo",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain JSON error");
let json: Value = serde_json::from_str(last_line).expect("valid JSON error");
assert_eq!(json["error"]["kind"], "missing-index");
}
#[test]
fn pack_named_query_flag_attaches_to_query_positional() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"pack",
"--query",
"foo",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
assert_ne!(output.status.code(), Some(2), "should not be a usage error");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("missing-index") || stderr.contains("not been initialized"),
"pack should reach runtime initialization handling, got: {stderr}"
);
}
fn assert_pack_alias_runs(alias: &str) {
let mut cmd = base_cmd();
cmd.args([
alias,
"auth",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
"--limit",
"1",
"--max-evidence",
"1",
"--max-sessions",
"1",
]);
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 pack JSON");
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v1"));
assert_eq!(json["query"]["text"].as_str(), Some("auth"));
assert_eq!(json["limits"]["max_evidence"].as_u64(), Some(1));
assert_eq!(json["limits"]["max_sessions"].as_u64(), Some(1));
}
#[test]
fn answer_alias_runs_pack_command() {
assert_pack_alias_runs("answer");
}
#[test]
fn handoff_alias_runs_pack_command() {
assert_pack_alias_runs("handoff");
}
#[test]
fn why_alias_runs_pack_command() {
assert_pack_alias_runs("why");
}
#[test]
fn explain_alias_runs_pack_command() {
assert_pack_alias_runs("explain");
}
#[test]
fn rca_alias_runs_pack_command() {
assert_pack_alias_runs("rca");
}
#[test]
fn view_named_path_flag_attaches_to_path_positional() {
let mut cmd = base_cmd();
cmd.args([
"view",
"--path",
"README.md",
"--json",
"--line",
"1",
"--context",
"0",
]);
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 view JSON");
assert_eq!(json["path"], "README.md");
assert_eq!(json["target_line"].as_u64(), Some(1));
}
#[test]
fn view_path_assignment_attaches_to_path_positional() {
let mut cmd = base_cmd();
cmd.args([
"view",
"path=README.md",
"--json",
"--line",
"1",
"--context",
"0",
]);
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 view JSON");
assert_eq!(json["path"], "README.md");
assert_eq!(json["target_line"].as_u64(), Some(1));
}
#[test]
fn view_path_line_shorthand_attaches_to_line_option() {
let mut cmd = base_cmd();
cmd.args(["view", "README.md:1", "--json", "--context", "0"]);
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 view JSON");
assert_eq!(json["path"], "README.md");
assert_eq!(json["target_line"].as_u64(), Some(1));
}
#[test]
fn view_line_and_context_assignments_attach_to_options() {
let mut cmd = base_cmd();
cmd.args(["view", "README.md", "line=1", "context=0", "--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 view JSON");
assert_eq!(json["path"], "README.md");
assert_eq!(json["target_line"].as_u64(), Some(1));
assert_eq!(
json["lines"].as_array().map(Vec::len),
Some(1),
"context=0 should produce only the target line"
);
}
#[test]
fn view_accepts_search_result_line_number_aliases() {
for line_arg in ["--line-number", "--line_number"] {
let mut cmd = base_cmd();
cmd.args([
"view",
"README.md",
line_arg,
"1",
"--context",
"0",
"--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 view JSON");
assert_eq!(json["path"], "README.md");
assert_eq!(json["target_line"].as_u64(), Some(1));
}
}
#[test]
fn view_line_number_assignment_attaches_to_line_option() {
let mut cmd = base_cmd();
cmd.args(["view", "README.md", "line_number=1", "context=0", "--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 view JSON");
assert_eq!(json["path"], "README.md");
assert_eq!(json["target_line"].as_u64(), Some(1));
assert_eq!(
json["lines"].as_array().map(Vec::len),
Some(1),
"context=0 should produce only the target line"
);
}
#[test]
fn view_search_hit_assignments_attach_to_path_source_and_line() {
let mut cmd = base_cmd();
cmd.args([
"view",
"source_path=README.md",
"source_id=local",
"line_number=1",
"context=0",
"--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 view JSON");
assert_eq!(json["path"], "README.md");
assert_eq!(json["target_line"].as_u64(), Some(1));
assert_eq!(
json["lines"].as_array().map(Vec::len),
Some(1),
"context=0 should produce only the target line"
);
}
#[test]
fn search_format_json_alias_attaches_to_robot_format() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"foo",
"--format",
"json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain JSON error");
let json: Value = serde_json::from_str(last_line).expect("valid JSON error");
assert_eq!(json["error"]["kind"], "missing-index");
}
#[test]
fn search_output_json_aliases_attach_to_robot_format() {
for format_args in [
vec!["--output", "json"],
vec!["--output=json"],
vec!["--output-format=json"],
vec!["--output_format", "json"],
] {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_str().unwrap();
let mut args = vec!["search", "foo"];
args.extend(format_args);
args.extend(["--data-dir", data_dir]);
let mut cmd = base_cmd();
cmd.args(args);
let output = cmd.assert().failure().get_output().clone();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain JSON error");
let json: Value = serde_json::from_str(last_line).expect("valid JSON error");
assert_eq!(json["error"]["kind"], "missing-index");
}
}
#[test]
fn status_format_json_alias_outputs_status_json() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"status",
"--format=json",
"--data-dir",
tmp.path().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 status JSON");
assert_eq!(json["status"], "not_initialized");
assert_eq!(json["initialized"], false);
}
#[test]
fn status_output_json_aliases_output_status_json() {
for format_args in [
vec!["status", "--output", "json"],
vec!["status", "--output=json"],
vec!["status", "--output-format=json"],
vec!["--output", "json", "status"],
] {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_str().unwrap();
let mut args = format_args;
args.extend(["--data-dir", data_dir]);
let mut cmd = base_cmd();
cmd.args(args);
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 status JSON");
assert_eq!(json["status"], "not_initialized");
assert_eq!(json["initialized"], false);
}
}
#[test]
fn leading_format_json_before_status_attaches_to_status() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"--format",
"json",
"status",
"--data-dir",
tmp.path().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 status JSON");
assert_eq!(json["status"], "not_initialized");
assert_eq!(json["initialized"], false);
}
#[test]
fn root_format_json_defaults_to_triage() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_string_lossy().to_string();
let mut cmd = base_cmd();
cmd.args(["--format", "json", "--data-dir", &data_dir]);
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 triage JSON");
assert_eq!(json["surface"], "triage");
assert_eq!(json["status"], "not_initialized");
assert_not_initialized_recommended_commands(&json, tmp.path());
}
#[test]
fn root_output_json_defaults_to_triage() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().to_string_lossy().to_string();
let mut cmd = base_cmd();
cmd.args(["--output", "json", "--data-dir", &data_dir]);
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 triage JSON");
assert_eq!(json["surface"], "triage");
assert_eq!(json["status"], "not_initialized");
assert_not_initialized_recommended_commands(&json, tmp.path());
}
#[test]
fn capabilities_format_json_alias_outputs_capabilities_json() {
let mut cmd = base_cmd();
cmd.args(["capabilities", "--format", "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 capabilities JSON");
assert_eq!(json["contract_version"], "1");
assert!(json["mistake_recoveries"].as_array().is_some());
}
#[test]
fn capabilities_output_json_alias_outputs_capabilities_json() {
let mut cmd = base_cmd();
cmd.args(["capabilities", "--output", "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 capabilities JSON");
assert_eq!(json["contract_version"], "1");
assert!(json["mistake_recoveries"].as_array().is_some());
}
#[test]
fn export_format_json_missing_path_is_not_robot_error_wrapped() {
let mut cmd = base_cmd();
cmd.args(["export", "--format", "json"]);
let output = cmd.assert().failure().code(2).get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain a usage error");
assert_eq!(last_line, "Could not parse arguments");
assert!(
serde_json::from_str::<Value>(last_line).is_err(),
"export --format json is the export format enum, not robot mode"
);
}
#[test]
fn export_output_json_missing_path_is_not_robot_error_wrapped() {
let mut cmd = base_cmd();
cmd.args(["export", "--output", "json"]);
let output = cmd.assert().failure().code(2).get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.expect("stderr should contain a usage error");
assert_eq!(last_line, "Could not parse arguments");
assert!(
serde_json::from_str::<Value>(last_line).is_err(),
"export --output json is an output file path, not robot mode"
);
}
#[test]
fn leading_json_before_robot_docs_is_removed_as_redundant() {
let mut cmd = base_cmd();
cmd.args(["--json", "robot-docs", "commands", "--color=never"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("commands:"));
assert!(stdout.contains("cass search <query>"));
assert!(!stdout.contains('\u{1b}'));
}
#[test]
fn introspect_includes_contract_and_globals() {
let mut cmd = base_cmd();
cmd.args(["introspect", "--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 introspect json");
assert_eq!(json["api_version"], 1);
assert_eq!(json["contract_version"], "1");
let globals = json["global_flags"].as_array().expect("global_flags array");
// Bead 7k7pl: pin that the well-known shared flags are present.
// `introspect` promises a stable contract for automation; a
// regression that shrank global_flags (dropping `db` which is
// the primary data-dir option, or `verbose` which every CLI
// tool relies on) would slip past `!is_empty()` while breaking
// every automation client that scripts against the flag list.
assert!(
globals.iter().any(|g| g["name"] == "db"),
"global_flags must include `db`; got {globals:?}"
);
assert!(
globals.iter().any(|g| g["name"] == "verbose"),
"global_flags must include `verbose`; got {globals:?}"
);
let commands = json["commands"].as_array().expect("commands array");
assert!(
commands.iter().any(|c| c["name"] == "api-version"),
"commands should include api-version"
);
}
/// Global flags should expose value types and defaults in introspect.
#[test]
fn introspect_global_flags_have_types_and_defaults() {
let mut cmd = base_cmd();
cmd.args(["introspect", "--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 introspect json");
let globals = json["global_flags"].as_array().expect("global_flags array");
let mut seen = std::collections::HashMap::new();
for flag in globals {
let name = flag["name"].as_str().unwrap_or_default().to_string();
seen.insert(name.clone(), flag.clone());
match name.as_str() {
"color" => {
assert_eq!(flag["value_type"], "enum");
assert_eq!(flag["default"], "auto");
let enums = flag["enum_values"].as_array().unwrap();
assert!(
enums.iter().any(|v| v == "auto"),
"color enum should have 'auto', got: {:?}",
enums
);
assert!(
enums.iter().any(|v| v == "never"),
"color enum should have 'never', got: {:?}",
enums
);
assert!(
enums.iter().any(|v| v == "always"),
"color enum should have 'always', got: {:?}",
enums
);
}
"progress" => {
assert_eq!(flag["value_type"], "enum");
assert_eq!(flag["default"], "auto");
let enums = flag["enum_values"].as_array().unwrap();
assert!(
enums.iter().any(|v| v == "auto"),
"progress enum should have 'auto', got: {:?}",
enums
);
assert!(
enums.iter().any(|v| v == "bars"),
"progress enum should have 'bars', got: {:?}",
enums
);
assert!(
enums.iter().any(|v| v == "plain"),
"progress enum should have 'plain', got: {:?}",
enums
);
assert!(
enums.iter().any(|v| v == "none"),
"progress enum should have 'none', got: {:?}",
enums
);
}
"db" => {
assert_eq!(flag["value_type"], "path");
}
"trace-file" => {
assert_eq!(flag["value_type"], "path");
}
"wrap" => {
assert_eq!(flag["value_type"], "integer");
}
"nowrap" => {
assert_eq!(flag["arg_type"], "flag");
}
_ => {}
}
}
for required in ["color", "progress", "db", "trace-file", "wrap", "nowrap"] {
assert!(
seen.contains_key(required),
"global flag {required} should be documented"
);
}
}
/// Introspect should mark repeatable args and detect path/integer types.
#[test]
fn introspect_repeatable_and_value_types() {
let mut cmd = base_cmd();
cmd.args(["introspect", "--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 introspect json");
let commands = json["commands"].as_array().expect("commands array");
let search = commands
.iter()
.find(|c| c["name"] == "search")
.expect("search command present");
let args = search["arguments"].as_array().expect("search args");
let mut found_agent = false;
let mut found_workspace = false;
let mut found_data_dir = false;
let mut found_limit = false;
let mut found_aggregate = false;
for arg in args {
let name = arg["name"].as_str().unwrap_or_default();
match name {
"agent" => {
found_agent = true;
assert_eq!(arg["repeatable"], true);
}
"workspace" => {
found_workspace = true;
assert_eq!(arg["repeatable"], true);
}
"data-dir" => {
found_data_dir = true;
assert_eq!(arg["value_type"], "path");
}
"limit" => {
found_limit = true;
assert_eq!(arg["value_type"], "integer");
assert_eq!(arg["default"], "0");
}
"aggregate" => {
found_aggregate = true;
assert_eq!(arg["repeatable"], true);
}
_ => {}
}
}
assert!(found_agent, "search should document repeatable agent arg");
assert!(
found_workspace,
"search should document repeatable workspace arg"
);
assert!(found_data_dir, "search should document data-dir path type");
assert!(found_limit, "search should document integer limit");
assert!(
found_aggregate,
"search should document repeatable aggregate"
);
}
#[test]
fn state_matches_status() -> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut status = base_cmd();
status.args(["status", "--json", "--data-dir", data_dir]);
let status_out = status.assert().success().get_output().clone();
let status_json: Value = serde_json::from_slice(&status_out.stdout).expect("valid status json");
let mut state = base_cmd();
state.args(["state", "--json", "--data-dir", data_dir]);
let state_out = state.assert().success().get_output().clone();
let state_json: Value = serde_json::from_slice(&state_out.stdout).expect("valid state json");
// Core assertion: status and state report the same health
assert_eq!(status_json["healthy"], state_json["healthy"]);
// Pending sessions should match between the two commands, regardless of the
// rebuild/watch state observed in the fixture dataset.
assert_eq!(
status_json["pending"]["sessions"],
state_json["pending"]["sessions"]
);
assert_eq!(status_json["semantic"], state_json["semantic"]);
Ok(())
}
#[test]
fn state_hides_empty_active_rebuild_pipeline_runtime_before_first_heartbeat()
-> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path();
let db_path = data_dir.join("agent_search.db");
let _lock_file = hold_active_lexical_rebuild_lock(data_dir, &db_path, false, None);
let mut cmd = base_cmd();
cmd.args([
"state",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
"--db",
db_path.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 state json");
assert_eq!(json["index"]["rebuilding"], Value::Bool(true));
assert_eq!(json["rebuild"]["active"], Value::Bool(true));
assert_eq!(json["rebuild"]["pipeline"]["runtime"], Value::Null);
Ok(())
}
#[test]
fn state_and_status_report_active_rebuild_pipeline_runtime() -> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path();
let db_path = data_dir.join("agent_search.db");
let expected_runtime = serde_json::json!({
"queue_depth": 3,
"inflight_message_bytes": 65_536,
"max_message_bytes_in_flight": 262_144,
"pending_batch_conversations": 9,
"pending_batch_message_bytes": 131_072,
"page_prep_workers": 6,
"active_page_prep_jobs": 2,
"ordered_buffered_pages": 4,
"budget_generation": 1,
"producer_budget_wait_count": 2,
"producer_budget_wait_ms": 17,
"producer_handoff_wait_count": 1,
"producer_handoff_wait_ms": 9,
"host_loadavg_1m_milli": 7_250,
"controller_mode": "pressure_limited",
"controller_reason": "queue_depth_3_reached_pipeline_capacity_3",
"staged_merge_workers_max": 3,
"staged_merge_allowed_jobs": 1,
"staged_merge_active_jobs": 1,
"staged_merge_ready_artifacts": 5,
"staged_merge_ready_groups": 1,
"staged_merge_controller_reason": "page_prep_workers_saturated_6_of_6",
"staged_shard_build_workers_max": 6,
"staged_shard_build_allowed_jobs": 5,
"staged_shard_build_active_jobs": 4,
"staged_shard_build_pending_jobs": 2,
"staged_shard_build_controller_reason": "reserving_1_slots_for_staged_merge_active_jobs_1_ready_groups_1",
"updated_at_ms": 1_733_000_124_000_i64
});
let _lock_file =
hold_active_lexical_rebuild_lock(data_dir, &db_path, false, Some(expected_runtime));
let run = |subcommand: &str| -> Value {
let mut cmd = base_cmd();
cmd.args([
subcommand,
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
"--db",
db_path.to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
serde_json::from_slice(&output.stdout).expect("valid robot json")
};
let state_json = run("state");
let status_json = run("status");
for json in [&state_json, &status_json] {
assert_eq!(json["index"]["rebuilding"], Value::Bool(true));
assert_eq!(json["rebuild"]["active"], Value::Bool(true));
}
let state_runtime = &state_json["rebuild"]["pipeline"]["runtime"];
let status_runtime = &status_json["rebuild"]["pipeline"]["runtime"];
assert_eq!(state_runtime, status_runtime);
assert_eq!(state_runtime["queue_depth"].as_u64(), Some(3));
assert_eq!(
state_runtime["inflight_message_bytes"].as_u64(),
Some(65_536)
);
assert_eq!(
state_runtime["max_message_bytes_in_flight"].as_u64(),
Some(262_144)
);
assert_eq!(
state_runtime["inflight_message_bytes_headroom"].as_u64(),
Some(196_608)
);
assert_eq!(
state_runtime["producer_budget_wait_count"].as_u64(),
Some(2)
);
assert_eq!(state_runtime["producer_budget_wait_ms"].as_u64(), Some(17));
assert_eq!(
state_runtime["producer_handoff_wait_count"].as_u64(),
Some(1)
);
assert_eq!(state_runtime["producer_handoff_wait_ms"].as_u64(), Some(9));
assert_eq!(state_runtime["host_loadavg_1m"].as_f64(), Some(7.25));
assert_eq!(
state_runtime["controller_mode"].as_str(),
Some("pressure_limited")
);
assert_eq!(
state_runtime["controller_reason"].as_str(),
Some("queue_depth_3_reached_pipeline_capacity_3")
);
assert_eq!(state_runtime["staged_merge_allowed_jobs"].as_u64(), Some(1));
assert_eq!(
state_runtime["staged_shard_build_pending_jobs"].as_u64(),
Some(2)
);
assert_eq!(
state_runtime["updated_at"].as_str(),
Some("2024-11-30T20:55:24+00:00")
);
Ok(())
}
#[test]
fn search_cursor_and_token_budget() {
let (_tmp, home, data_dir) = seed_metamorphic_corpus();
let data_dir = data_dir.to_str().expect("utf8 temp data dir");
// First page with small token budget to force clamping
let mut first = isolated_cass_cmd(&home);
first.args([
"search",
"metamorphprobe",
"--json",
"--limit",
"2",
"--robot-meta",
"--fields",
"content",
"--max-tokens",
"16",
"--request-id",
"rid-123",
"--data-dir",
data_dir,
]);
let first_out = first.assert().success().get_output().clone();
let first_json: Value = serde_json::from_slice(&first_out.stdout).expect("valid search json");
assert_eq!(first_json["request_id"], "rid-123");
let first_hits = first_json["hits"].as_array().expect("hits array");
if first_hits.is_empty()
&& first_json["_meta"]
.get("next_cursor")
.and_then(|c| c.as_str())
.is_none()
{
assert_eq!(first_json["count"].as_u64().unwrap_or(0), 0);
return;
}
let first_meta = first_json
.get("_meta")
.and_then(Value::as_object)
.expect("token-budgeted robot search should include _meta");
let first_manifest = first_meta
.get("cursor_manifest")
.and_then(Value::as_object)
.expect("_meta.cursor_manifest should be present");
assert_eq!(
first_manifest
.get("requested_limit")
.and_then(Value::as_u64),
Some(2)
);
assert_eq!(
first_manifest.get("realized_limit").and_then(Value::as_u64),
Some(2)
);
assert_eq!(
first_json.get("hits_clamped").and_then(Value::as_bool),
Some(true),
"small max_tokens should clamp the content-only result page"
);
assert_eq!(
first_manifest
.get("token_budget")
.and_then(|v| v.get("hits_clamped"))
.and_then(Value::as_bool),
Some(true),
"cursor manifest should mirror token-budget clamping"
);
assert_eq!(
first_manifest.get("returned_count").and_then(Value::as_u64),
first_json.get("count").and_then(Value::as_u64),
"cursor manifest should track the emitted hit count after token clamping"
);
assert!(
first_manifest
.get("count_precision")
.and_then(Value::as_str)
.is_some(),
"cursor manifest should explain total_matches precision"
);
assert_eq!(
first_manifest
.get("field_mask")
.and_then(|v| v.get("projection"))
.and_then(Value::as_str),
Some("custom"),
"cursor manifest should preserve the realized field projection"
);
if let Some(cursor) = first_json["_meta"]
.get("next_cursor")
.and_then(|c| c.as_str())
{
assert_eq!(
first_manifest.get("has_more").and_then(Value::as_bool),
Some(true),
"cursor manifest should expose has_more when next_cursor is emitted"
);
assert_eq!(
first_manifest
.get("next_cursor_present")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
decoded_cursor_offset(cursor),
first_json["count"].as_u64().unwrap_or(0),
"token-budget continuation must advance by emitted hits, not hidden pre-clamp hits"
);
// Second page using cursor should succeed and echo request_id if provided again
let mut second = isolated_cass_cmd(&home);
second.args([
"search",
"metamorphprobe",
"--json",
"--cursor",
cursor,
"--robot-meta",
"--fields",
"content",
"--max-tokens",
"16",
"--request-id",
"rid-456",
"--data-dir",
data_dir,
]);
let second_out = second.assert().success().get_output().clone();
let second_json: Value =
serde_json::from_slice(&second_out.stdout).expect("valid search json");
assert_eq!(second_json["request_id"], "rid-456");
// Cursor page should not be empty
let count = second_json["count"].as_u64().unwrap_or(0);
assert!(count > 0, "cursor page should return results");
if let Some(cursor) = second_json["_meta"]
.get("next_cursor")
.and_then(Value::as_str)
{
let mut third = isolated_cass_cmd(&home);
third.args([
"search",
"metamorphprobe",
"--json",
"--cursor",
cursor,
"--robot-meta",
"--fields",
"content",
"--max-tokens",
"16",
"--request-id",
"rid-789",
"--data-dir",
data_dir,
]);
let third_out = third.assert().success().get_output().clone();
let third_json: Value =
serde_json::from_slice(&third_out.stdout).expect("valid third search json");
assert_eq!(third_json["request_id"], "rid-789");
}
} else {
// If dataset is too small for pagination, ensure we returned some hits
assert!(
first_json["hits"]
.as_array()
.map(|h| !h.is_empty())
.unwrap_or(false)
);
}
}
#[test]
fn search_cursor_jsonl_and_compact() {
let data_dir = "tests/fixtures/search_demo_data";
// JSONL meta line contains next_cursor
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--robot-format",
"jsonl",
"--robot-meta",
"--limit",
"2",
"--data-dir",
data_dir,
]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
let first_line = stdout.lines().next().expect("meta line present");
let meta: Value = serde_json::from_str(first_line).expect("valid jsonl meta");
// Bead 7k7pl: pin `_meta` as a JSON object and `next_cursor` as
// either a string (more pages available) or null (exhausted).
// A regression that emitted a scalar/array/object for the cursor
// would slip past `.is_some()` while breaking CLI clients that
// branch on `cursor === null` vs string.
assert!(
meta.get("_meta").and_then(|v| v.as_object()).is_some(),
"_meta must be a JSON object; got {meta}"
);
let next_cursor = meta["_meta"]
.get("next_cursor")
.expect("next_cursor key must be present");
assert!(
next_cursor.is_string() || next_cursor.is_null(),
"next_cursor must be string-or-null; got {meta}"
);
let manifest = meta["_meta"]
.get("cursor_manifest")
.and_then(Value::as_object)
.expect("jsonl _meta should include cursor manifest");
assert!(
manifest.get("has_more").and_then(Value::as_bool).is_some(),
"cursor manifest should expose has_more; got {meta}"
);
assert!(
manifest
.get("continuation_safe")
.and_then(Value::as_bool)
.is_some(),
"cursor manifest should expose continuation safety; got {meta}"
);
// Compact still returns cursor in payload
let mut compact = base_cmd();
compact.args([
"search",
"hello",
"--robot-format",
"compact",
"--robot-meta",
"--limit",
"2",
"--data-dir",
data_dir,
]);
let compact_out = compact.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&compact_out.stdout).expect("compact json payload");
// Bead 7k7pl: pin next_cursor shape (string-or-null) in the
// compact-format payload too — both robot formats share the
// same cursor contract.
let next_cursor = json["_meta"]
.get("next_cursor")
.expect("next_cursor key must be present in compact payload");
assert!(
next_cursor.is_string() || next_cursor.is_null(),
"next_cursor must be string-or-null in compact payload; got {json}"
);
let manifest = json["_meta"]
.get("cursor_manifest")
.and_then(Value::as_object)
.expect("compact _meta should include cursor manifest");
assert!(
manifest
.get("cache_generation")
.and_then(Value::as_object)
.is_some(),
"cursor manifest should expose cache generation metadata; got {json}"
);
}
#[test]
fn search_robot_format_sessions_matches_source_paths() {
// rob.ctx.sessions: sessions output should match the unique sorted source_path set from JSON hits.
let data_dir = "tests/fixtures/search_demo_data";
// 1) Get source_path values via compact JSON.
let mut compact = base_cmd();
compact.args([
"search",
"hello",
"--robot-format",
"compact",
"--fields",
"minimal",
"--limit",
"50",
"--data-dir",
data_dir,
]);
let compact_out = compact.assert().success().get_output().clone();
let json: Value = serde_json::from_slice(&compact_out.stdout).expect("compact json payload");
let hits = json["hits"].as_array().expect("hits array");
let mut expected: Vec<String> = hits
.iter()
.filter_map(|h| {
h.get("source_path")
.and_then(|p| p.as_str())
.map(str::to_string)
})
.collect();
expected.sort();
expected.dedup();
// 2) Get session paths via sessions robot format.
let mut sessions = base_cmd();
sessions.args([
"search",
"hello",
"--robot-format",
"sessions",
"--limit",
"50",
"--data-dir",
data_dir,
]);
let sessions_out = sessions.assert().success().get_output().clone();
let actual: Vec<String> = String::from_utf8_lossy(&sessions_out.stdout)
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
assert_eq!(
actual, expected,
"sessions output should equal unique sorted hit source_path values"
);
}
#[test]
fn robot_docs_schemas_topic() {
let mut cmd = base_cmd();
cmd.args(["robot-docs", "schemas"]);
cmd.assert()
.success()
.stdout(contains("schemas:"))
.stdout(contains("search"));
}
#[test]
fn robot_docs_commands_includes_tui_reset_and_no_ansi() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "commands"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs commands should not emit ANSI when color=never"
);
assert!(
stdout.contains("cass tui"),
"commands topic should list cass tui"
);
assert!(
stdout.contains("cass robot-docs <topic>"),
"commands topic should list robot-docs command"
);
}
#[test]
fn robot_docs_env_lists_key_vars_and_no_ansi() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "env"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs env should not emit ANSI when color=never"
);
for needle in &[
"CODING_AGENT_SEARCH_NO_UPDATE_PROMPT",
"CASS_DATA_DIR",
"TUI_HEADLESS",
] {
assert!(stdout.contains(needle), "env topic should include {needle}");
}
}
fn read_fixture(name: &str) -> Value {
let path = Path::new("tests/fixtures/cli_contract").join(name);
let body = fs::read_to_string(&path).expect("fixture readable");
serde_json::from_str(&body).expect("fixture valid json")
}
fn read_robot_json_golden(name: &str) -> Value {
let path = Path::new("tests/golden/robot").join(name);
let body = fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("golden {} readable: {err}", path.display()));
let body = body.replace("[VERSION]", env!("CARGO_PKG_VERSION"));
serde_json::from_str(&body)
.unwrap_or_else(|err| panic!("golden {} valid json: {err}", path.display()))
}
#[test]
fn swarm_status_fixture_outputs_match_goldens() {
for fixture_id in [
"healthy",
"busy",
"stale_advisory",
"reservation_conflict",
"unrelated_reservation",
"build_pressure",
"no_ready_work",
"privacy_guardrails",
] {
let mut cmd = base_cmd();
cmd.args([
"swarm",
"status",
"--json",
"--fixture-dir",
"tests/fixtures/swarm_status",
"--fixture-id",
fixture_id,
]);
let output = cmd.assert().success().get_output().clone();
assert!(
output.stderr.is_empty(),
"{fixture_id} swarm status should not log to stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let actual: Value =
serde_json::from_slice(&output.stdout).expect("valid swarm status json");
let golden_path =
Path::new("tests/golden/swarm_status").join(format!("{fixture_id}.json.golden"));
let expected: Value = serde_json::from_str(
&fs::read_to_string(&golden_path)
.unwrap_or_else(|err| panic!("read {}: {err}", golden_path.display())),
)
.unwrap_or_else(|err| panic!("parse {}: {err}", golden_path.display()));
assert_eq!(actual, expected, "{fixture_id} swarm status golden drifted");
}
}
#[test]
fn capabilities_matches_golden_contract() {
let mut cmd = base_cmd();
cmd.args(["capabilities", "--json"]);
let output = cmd.assert().success().get_output().clone();
assert!(
output.stderr.is_empty(),
"capabilities should not log to stderr"
);
let actual: Value = serde_json::from_slice(&output.stdout).expect("valid capabilities json");
let expected = read_robot_json_golden("capabilities.json.golden");
// Verify crate_version matches Cargo.toml (dynamic, not from fixture)
let cargo_version = env!("CARGO_PKG_VERSION");
assert_eq!(
actual["crate_version"].as_str().unwrap(),
cargo_version,
"crate_version should match Cargo.toml version"
);
assert_eq!(actual, expected, "capabilities contract drifted");
}
#[test]
fn api_version_matches_golden_contract() {
let mut cmd = base_cmd();
cmd.args(["api-version", "--json"]);
let output = cmd.assert().success().get_output().clone();
assert!(
output.stderr.is_empty(),
"api-version should not log to stderr"
);
let actual: Value = serde_json::from_slice(&output.stdout).expect("valid api-version json");
// Check stable contract fields against fixture
let expected = read_fixture("api_version.json");
assert_eq!(
actual["api_version"], expected["api_version"],
"api_version field drifted"
);
assert_eq!(
actual["contract_version"], expected["contract_version"],
"contract_version field drifted"
);
// Verify crate_version matches Cargo.toml (dynamic, not from fixture)
let cargo_version = env!("CARGO_PKG_VERSION");
assert_eq!(
actual["crate_version"].as_str().unwrap(),
cargo_version,
"crate_version should match Cargo.toml version"
);
}
#[test]
fn color_never_has_no_ansi() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "--robot-help"]);
cmd.assert()
.success()
.stdout(contains("cass --robot-help"))
.stdout(predicate::str::contains("\u{1b}").not());
}
#[test]
fn wrap_40_inserts_line_breaks() {
let mut cmd = base_cmd();
cmd.args(["--wrap=40", "--robot-help"]);
cmd.assert()
.success()
// With wrap at 40, long command examples should wrap across lines
.stdout(contains("--robot #\nSearch with JSON output"));
}
#[test]
fn tui_bypasses_in_non_tty() {
let mut cmd = base_cmd();
// No subcommand provided; in test harness stdout is non-TTY so TUI should be blocked
cmd.assert()
.failure()
.code(2)
.stderr(contains("TUI is disabled"));
}
#[test]
fn search_error_writes_trace() {
let tmp = TempDir::new().unwrap();
let trace_path = tmp.path().join("trace.jsonl");
let mut cmd = base_cmd();
cmd.args([
"--trace-file",
trace_path.to_str().unwrap(),
"--progress=plain",
"search",
"foo",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let assert = cmd.assert().failure();
let output = assert.get_output().clone();
let code = output.status.code().expect("exit code present");
// Accept both missing-index (3) and generic search error (9) depending on how the DB layer responds.
assert!(matches!(code, 3 | 9), "unexpected exit code {code}");
let stderr = String::from_utf8_lossy(&output.stderr);
if code == 3 {
assert!(stderr.contains("missing-index"));
} else {
assert!(stderr.contains("\"kind\":\"search\""));
}
let trace = fs::read_to_string(&trace_path).expect("trace file exists");
let last_line = trace.lines().last().expect("trace line present");
let json: Value = serde_json::from_str(last_line).expect("valid trace json");
let exit_code = json["exit_code"].as_i64().expect("exit_code present");
assert_eq!(exit_code, code as i64);
assert_eq!(json["contract_version"], "1");
}
// ============================================================
// yln.5: E2E Search Tests with Fixture Data
// ============================================================
#[test]
fn search_returns_json_results() {
// E2E test: search with JSON output returns structured results (yln.5)
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse JSON output
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON output");
// Verify structure
assert!(json["count"].is_number(), "JSON should have count field");
assert!(json["hits"].is_array(), "JSON should have hits array");
// Verify hit structure
let hits = json["hits"].as_array().unwrap();
if hits.is_empty() {
return;
}
let first_hit = &hits[0];
assert!(first_hit["agent"].is_string(), "Hit should have agent");
assert!(
first_hit["source_path"].is_string(),
"Hit should have source_path"
);
assert!(first_hit["score"].is_number(), "Hit should have score");
}
#[test]
fn search_respects_limit() {
// E2E test: --limit restricts results (yln.5)
let mut cmd = base_cmd();
cmd.args([
"search",
"Gemini",
"--json",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
assert!(
hits.len() <= 1,
"Limit should restrict results to at most 1"
);
}
#[test]
fn search_empty_query_returns_all() {
// E2E test: empty query returns recent results (yln.5)
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Empty query should return results (recent conversations)
let hits = json["hits"].as_array().expect("Should return hits array");
assert!(
json["count"].is_number(),
"empty-query robot output should still report total count"
);
assert!(
hits.len() <= json["count"].as_u64().unwrap() as usize,
"reported count should be at least the returned page length"
);
}
fn assert_search_limit_alias_limits_to_one(alias_args: &[&str]) {
let mut cmd = base_cmd();
let mut args = vec!["search", "", "--json"];
args.extend(alias_args.iter().copied());
args.extend(["--data-dir", "tests/fixtures/search_demo_data"]);
cmd.args(args);
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 JSON");
let hits = json["hits"].as_array().expect("hits array");
assert_eq!(json["limit"].as_u64(), Some(1));
assert_eq!(json["count"].as_u64(), Some(1));
assert_eq!(hits.len(), 1);
}
#[test]
fn search_max_results_alias_attaches_to_limit() {
assert_search_limit_alias_limits_to_one(&["--max-results", "1"]);
}
#[test]
fn search_snake_case_max_results_flag_attaches_to_limit() {
assert_search_limit_alias_limits_to_one(&["--max_results", "1"]);
}
#[test]
fn search_snake_case_max_results_equals_attaches_to_limit() {
assert_search_limit_alias_limits_to_one(&["--max_results=1"]);
}
#[test]
fn search_count_alias_attaches_to_limit() {
assert_search_limit_alias_limits_to_one(&["--count=1"]);
}
#[test]
fn search_limit_assignment_attaches_to_limit() {
assert_search_limit_alias_limits_to_one(&["limit=1"]);
}
#[test]
fn search_max_results_assignment_attaches_to_limit() {
assert_search_limit_alias_limits_to_one(&["max_results=1"]);
}
#[test]
fn search_filter_assignments_attach_to_options() {
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"limit=1",
"agent=aider",
"fields=minimal",
"mode=lexical",
"data_dir=tests/fixtures/search_demo_data",
]);
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 JSON");
let hits = json["hits"].as_array().expect("hits array");
assert_eq!(json["limit"].as_u64(), Some(1));
assert_eq!(hits.len(), 1);
let hit = &hits[0];
assert!(
hit["source_path"].is_string(),
"minimal preset keeps source_path"
);
assert!(
hit["line_number"].is_number(),
"minimal preset keeps line_number"
);
assert_eq!(hit["agent"].as_str(), Some("aider"));
assert!(hit["content"].is_null(), "minimal preset omits content");
}
#[test]
fn search_time_window_assignments_attach_to_options() {
let mut cmd = base_cmd();
cmd.args([
"search",
"time window sentinel",
"--json",
"--dry-run",
"last=7d",
"before=now",
"data_dir=tests/fixtures/search_demo_data",
]);
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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true));
assert_eq!(json["valid"].as_bool(), Some(true));
assert_eq!(json["query"].as_str(), Some("time window sentinel"));
}
#[test]
fn search_since_now_assignment_filters_to_zero_hits() {
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"since=now",
"limit=1",
"data_dir=tests/fixtures/search_demo_data",
]);
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 JSON");
let hits = json["hits"].as_array().expect("hits array");
assert_eq!(json["count"].as_u64(), Some(0));
assert!(hits.is_empty());
}
#[test]
fn search_time_window_alias_flags_filter_to_zero_hits() {
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--last",
"7",
"--before=now",
"limit=1",
"data_dir=tests/fixtures/search_demo_data",
]);
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 JSON");
let hits = json["hits"].as_array().expect("hits array");
assert_eq!(json["count"].as_u64(), Some(0));
assert!(hits.is_empty());
}
#[test]
fn search_short_n_alias_attaches_to_limit() {
assert_search_limit_alias_limits_to_one(&["-n", "1"]);
}
#[test]
fn search_no_match_returns_empty_hits() {
// E2E test: non-matching query returns empty results (yln.5)
let mut cmd = base_cmd();
cmd.args([
"search",
"xyznonexistentquery12345",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let count = json["count"].as_u64().expect("count field");
assert_eq!(count, 0, "Non-matching query should return 0 results");
let hits = json["hits"].as_array().expect("hits array");
assert!(hits.is_empty(), "Hits array should be empty");
}
// Test `include_attachments_flag_hidden_from_pages_help` removed:
// the --include-attachments flag has been removed from the pages CLI
// surface (bead adyyt). The flag was accepted but unimplemented; removal
// eliminates the mock-code surface entirely.
#[test]
fn search_writes_trace_on_success() {
// E2E test: trace file captures successful search (yln.5)
let tmp = TempDir::new().unwrap();
let trace_path = tmp.path().join("search_trace.jsonl");
let mut cmd = base_cmd();
cmd.args([
"--trace-file",
trace_path.to_str().unwrap(),
"search",
"hello",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
cmd.assert().success();
// Verify trace file was written
let trace = fs::read_to_string(&trace_path).expect("trace file exists");
assert!(!trace.is_empty(), "Trace file should have content");
// Parse last line as JSON
let last_line = trace.lines().last().expect("trace has lines");
let json: Value = serde_json::from_str(last_line).expect("valid trace JSON");
assert_eq!(
json["exit_code"], 0,
"Successful search should have exit_code 0"
);
assert_eq!(json["contract_version"], "1");
}
#[test]
fn search_missing_index_returns_json_error_contract() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"foo",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
// Parse last non-empty line to be robust to any stray warnings
let last_line = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.expect("stderr should contain a JSON error line");
let val: Value =
serde_json::from_str(last_line.trim()).expect("stderr should contain JSON error payload");
let err = val
.get("error")
.and_then(|e| e.as_object())
.expect("error object present");
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
assert_ne!(code, 0, "error code should be non-zero");
assert!(
err.get("kind").and_then(|k| k.as_str()).is_some(),
"error kind should be present"
);
// Bead 7k7pl: pin TYPE on the error contract — `message` must be
// a non-empty string and `retryable` must be a boolean. A
// regression that emitted `null` message or numeric retryable
// would slip past `.is_some()` while breaking every CLI client
// that branches on the retryable bool.
let message = err
.get("message")
.and_then(|m| m.as_str())
.expect("message must be a string");
assert!(
!message.is_empty(),
"error message must be non-empty; got {err:?}"
);
assert!(
err.get("retryable").and_then(|r| r.as_bool()).is_some(),
"retryable must be a boolean; got {err:?}"
);
}
#[test]
fn search_missing_index_returns_json_error_contract_with_robot_format_compact() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"foo",
"--robot-format",
"compact",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.expect("stderr should contain a JSON error line");
let val: Value =
serde_json::from_str(last_line.trim()).expect("stderr should contain JSON error payload");
assert_eq!(
val["error"]["kind"],
Value::String("missing-index".to_string())
);
}
#[test]
fn search_dry_run_does_not_require_initialized_index() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"dry run sentinel",
"--robot",
"--dry-run",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let val: Value = serde_json::from_str(&stdout).expect("stdout should be dry-run JSON");
assert_eq!(val["dry_run"].as_bool(), Some(true));
assert_eq!(val["valid"].as_bool(), Some(true));
assert_eq!(val["query"].as_str(), Some("dry run sentinel"));
assert_eq!(val["_meta"]["dry_run"].as_bool(), Some(true));
}
#[test]
fn search_missing_index_returns_json_error_contract_with_env_output_format() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.env("CASS_OUTPUT_FORMAT", "compact");
cmd.args(["search", "foo", "--data-dir", tmp.path().to_str().unwrap()]);
let output = cmd.assert().failure().get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.expect("stderr should contain a JSON error line");
let val: Value =
serde_json::from_str(last_line.trim()).expect("stderr should contain JSON error payload");
assert_eq!(
val["error"]["kind"],
Value::String("missing-index".to_string())
);
}
#[test]
fn stats_missing_index_returns_json_error_contract() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"stats",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let output = cmd.assert().failure().get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
let last_line = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.expect("stderr should contain a JSON error line");
let val: Value =
serde_json::from_str(last_line.trim()).expect("stderr should contain JSON error payload");
let err = val
.get("error")
.and_then(|e| e.as_object())
.expect("error object present");
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
assert_ne!(code, 0, "error code should be non-zero");
assert!(
err.get("kind").and_then(|k| k.as_str()).is_some(),
"error kind should be present"
);
assert!(
err.get("retryable").is_some(),
"retryable flag should be present"
);
}
#[test]
fn search_json_includes_match_type() {
// E2E test: JSON results include match_type field (yln.5)
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if !hits.is_empty() {
let first_hit = &hits[0];
assert!(
first_hit["match_type"].is_string(),
"Hit should include match_type (exact/wildcard/fuzzy)"
);
}
}
#[test]
fn search_robot_format_is_valid_json_lines() {
// E2E test: --robot output is JSON lines format (yln.5)
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--robot",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
// Robot mode should output JSON (same as --json)
let json: Value =
serde_json::from_str(stdout.trim()).expect("robot output should be valid JSON");
assert!(
json["hits"].is_array(),
"Robot output should have hits array"
);
}
#[test]
fn search_robot_meta_includes_fallback_and_cache_stats() {
// CLI should surface wildcard_fallback + cache stats when --robot-meta is set
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--robot-meta",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let meta = json
.get("_meta")
.and_then(|m| m.as_object())
.expect("_meta present when --robot-meta is used");
assert!(
meta.get("wildcard_fallback").is_some(),
"_meta should include wildcard_fallback flag"
);
assert_eq!(
meta.get("requested_search_mode").and_then(Value::as_str),
Some("hybrid"),
"default robot search intent should be hybrid-preferred"
);
assert_eq!(
meta.get("search_mode").and_then(Value::as_str),
Some("lexical"),
"fixture without semantic assets should report realized lexical fallback"
);
assert_eq!(
meta.get("mode_defaulted").and_then(Value::as_bool),
Some(true),
"metadata should distinguish defaulted search intent"
);
assert_eq!(
meta.get("fallback_tier").and_then(Value::as_str),
Some("lexical"),
"metadata should name the realized fallback tier"
);
assert_eq!(
meta.get("semantic_refinement").and_then(Value::as_bool),
Some(false),
"lexical fallback should not claim semantic refinement"
);
let cache = meta
.get("cache_stats")
.and_then(|c| c.as_object())
.expect("_meta.cache_stats should be present");
assert!(
cache.contains_key("hits")
&& cache.contains_key("misses")
&& cache.contains_key("shortfall")
&& cache.contains_key("prewarm_scheduled")
&& cache.contains_key("prewarm_skipped_pressure"),
"cache_stats should expose cache and adaptive prewarm counters"
);
let query_plan = meta
.get("query_plan")
.and_then(Value::as_object)
.expect("_meta.query_plan should be present");
assert_eq!(
query_plan.get("planner_id").and_then(Value::as_str),
Some("query_cost.v1"),
"query_plan should name the stable planner contract"
);
let phases = query_plan
.get("phases")
.and_then(Value::as_array)
.expect("query_plan.phases should be an array");
let semantic_phase = phases
.iter()
.find(|phase| phase.get("phase").and_then(Value::as_str) == Some("semantic"))
.expect("query_plan should include semantic phase");
assert_eq!(
semantic_phase.get("planned").and_then(Value::as_bool),
Some(true),
"default hybrid search plans semantic refinement"
);
assert_eq!(
semantic_phase.get("realized").and_then(Value::as_bool),
Some(false),
"fixture without semantic assets should not claim semantic realization"
);
let result_identity = query_plan
.get("result_identity")
.and_then(Value::as_object)
.expect("query_plan.result_identity should be present");
assert_eq!(
result_identity
.get("returned_count")
.and_then(Value::as_u64),
json.get("count").and_then(Value::as_u64),
"query_plan should preserve the visible result count"
);
assert_eq!(
result_identity.get("total_matches").and_then(Value::as_u64),
json.get("total_matches").and_then(Value::as_u64),
"query_plan should preserve total_matches semantics"
);
let planned_cache = query_plan
.get("cache")
.and_then(Value::as_object)
.expect("query_plan.cache should be present");
assert!(
planned_cache.contains_key("hits")
&& planned_cache.contains_key("misses")
&& planned_cache.contains_key("shortfall"),
"query_plan.cache should mirror cache truth counters"
);
let cursor_manifest = meta
.get("cursor_manifest")
.and_then(Value::as_object)
.expect("_meta.cursor_manifest should be present");
let next_cursor_present = meta.get("next_cursor").is_some_and(Value::is_string);
assert_eq!(
cursor_manifest.get("has_more").and_then(Value::as_bool),
Some(next_cursor_present),
"cursor manifest should explain next_cursor availability"
);
assert!(
matches!(
cursor_manifest
.get("count_precision")
.and_then(Value::as_str),
Some("exact" | "lower_bound")
),
"cursor manifest should explain total count precision"
);
assert!(
cursor_manifest
.get("index_generation")
.and_then(|v| v.get("stale"))
.is_some(),
"cursor manifest should carry index generation staleness"
);
assert_eq!(
cursor_manifest
.get("semantic_fallback")
.and_then(|v| v.get("fallback_tier"))
.and_then(Value::as_str),
Some("lexical"),
"cursor manifest should mirror semantic fallback state"
);
let explanation_cards = meta
.get("explanation_cards")
.and_then(Value::as_array)
.expect("_meta.explanation_cards should be present");
assert!(
explanation_cards
.iter()
.any(|card| card.get("decision").and_then(Value::as_str) == Some("search_fallback")),
"explanation cards should include the search fallback decision"
);
assert!(
explanation_cards.iter().any(
|card| card.get("decision").and_then(Value::as_str) == Some("semantic_unavailable")
),
"explanation cards should include semantic unavailability when hybrid fails open"
);
}
#[test]
fn search_cursor_manifest_marks_rebuilding_generation_best_effort() -> Result<(), Box<dyn Error>> {
let data_dir = isolated_search_demo_data()?;
let db_path = data_dir.path().join("agent_search.db");
let _lock = hold_active_lexical_rebuild_lock(data_dir.path(), &db_path, true, None);
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--robot-meta",
"--limit",
"1",
"--data-dir",
data_dir.path().to_str().expect("utf8 fixture path"),
]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let manifest = json["_meta"]
.get("cursor_manifest")
.and_then(Value::as_object)
.expect("_meta.cursor_manifest should be present");
assert_eq!(
manifest
.get("index_generation")
.and_then(|v| v.get("rebuilding"))
.and_then(Value::as_bool),
Some(true),
"cursor manifest should surface active lexical generation rebuilds"
);
if manifest.get("next_cursor_present").and_then(Value::as_bool) == Some(true) {
assert_eq!(
manifest.get("continuation_safe").and_then(Value::as_bool),
Some(false),
"active generation rebuilds should make cursor continuation best-effort"
);
assert!(
manifest
.get("continuation_reason")
.and_then(Value::as_str)
.unwrap_or_default()
.contains("rebuilding"),
"continuation reason should explain the rebuild state"
);
}
let explanation_cards = json["_meta"]
.get("explanation_cards")
.and_then(Value::as_array)
.expect("_meta.explanation_cards should be present");
assert!(
explanation_cards
.iter()
.any(|card| card.get("decision").and_then(Value::as_str) == Some("rebuild_throttle")),
"explanation cards should include rebuild throttle while rebuild is active"
);
Ok(())
}
#[test]
fn search_robot_meta_reports_explicit_hybrid_fail_open() {
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--robot-meta",
"--mode",
"hybrid",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let meta = json
.get("_meta")
.and_then(Value::as_object)
.expect("_meta present when --robot-meta is used");
assert_eq!(
meta.get("requested_search_mode").and_then(Value::as_str),
Some("hybrid"),
"explicit hybrid intent should be preserved"
);
assert_eq!(
meta.get("search_mode").and_then(Value::as_str),
Some("lexical"),
"hybrid should fail open to lexical when semantic assets are absent"
);
assert_eq!(
meta.get("mode_defaulted").and_then(Value::as_bool),
Some(false),
"explicit --mode hybrid should not be reported as defaulted"
);
assert_eq!(
meta.get("fallback_tier").and_then(Value::as_str),
Some("lexical"),
"metadata should name the fail-open tier"
);
assert_eq!(
meta.get("semantic_refinement").and_then(Value::as_bool),
Some(false),
"lexical fail-open should not claim semantic refinement"
);
}
#[test]
fn search_robot_meta_reports_explicit_lexical_override() {
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--robot-meta",
"--mode",
"lexical",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let parsed = serde_json::from_str::<Value>(stdout.trim());
assert!(
parsed.is_ok(),
"robot output should be valid JSON: {:?}",
parsed.as_ref().err()
);
let Ok(json) = parsed else {
return;
};
let meta = json.get("_meta").and_then(Value::as_object);
assert!(
meta.is_some(),
"_meta should be present when --robot-meta is used"
);
let Some(meta) = meta else {
return;
};
assert_eq!(
meta.get("requested_search_mode").and_then(Value::as_str),
Some("lexical"),
"explicit lexical intent should be preserved"
);
assert_eq!(
meta.get("search_mode").and_then(Value::as_str),
Some("lexical"),
"explicit lexical mode should realize lexical search"
);
assert_eq!(
meta.get("mode_defaulted").and_then(Value::as_bool),
Some(false),
"explicit --mode should not be reported as defaulted"
);
assert_eq!(
meta.get("fallback_tier"),
Some(&Value::Null),
"explicit lexical mode should not report fallback"
);
assert_eq!(
meta.get("semantic_refinement").and_then(Value::as_bool),
Some(false),
"lexical-only override should not claim semantic refinement"
);
}
#[test]
fn stats_json_reports_counts() -> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args(["stats", "--json", "--data-dir", data_dir]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(
json["conversations"].as_i64().unwrap_or(0) > 0,
"stats should report conversations > 0"
);
assert!(
json["messages"].as_i64().unwrap_or(0) > 0,
"stats should report messages > 0"
);
assert!(
json["by_agent"].is_array(),
"stats should include per-agent breakdown"
);
Ok(())
}
#[test]
fn diag_json_reports_database_state() -> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args(["diag", "--json", "--data-dir", data_dir]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(
json["database"]["exists"],
Value::Bool(true),
"diag should detect database file"
);
assert!(
json["database"]["conversations"].as_i64().unwrap_or(0) > 0,
"diag should report conversation count"
);
assert!(
json["paths"]["data_dir"].is_string(),
"diag should include data_dir path"
);
Ok(())
}
#[test]
fn status_json_reports_index_health() -> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args(["status", "--json", "--data-dir", data_dir]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(
json["database"]["exists"].as_bool().unwrap_or(false),
"status should report database exists"
);
// Note: index.exists may be false for fixture data without tantivy index
assert!(json["index"].is_object(), "status should have index object");
// recommended_action may be null when healthy, so check it's present in the response
assert!(
json.get("recommended_action").is_some(),
"status should include recommended_action field"
);
Ok(())
}
#[test]
fn view_json_highlights_requested_line() {
let mut cmd = base_cmd();
cmd.args([
"view",
"tests/fixtures/amp/thread-001.json",
"--json",
"-n",
"5",
"-C",
"0",
]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(
json["target_line"].as_u64(),
Some(5),
"target_line should reflect requested line"
);
let lines = json["lines"].as_array().expect("lines array");
assert_eq!(lines.len(), 1, "context 0 should return single line");
assert_eq!(
lines[0]["line"].as_u64(),
Some(5),
"line number should match requested"
);
assert!(
lines[0]["highlighted"].as_bool().unwrap_or(false),
"requested line should be highlighted"
);
assert!(
lines[0]["content"]
.as_str()
.unwrap_or_default()
.contains("\"Hello\""),
"content should include requested line text"
);
}
#[test]
fn introspect_json_lists_commands() {
let mut cmd = base_cmd();
cmd.args(["introspect", "--json"]);
let assert = cmd.assert().success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let commands = json["commands"].as_array().expect("commands array");
let names: Vec<String> = commands
.iter()
.filter_map(|c| c["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(
names.contains(&"search".to_string()) && names.contains(&"status".to_string()),
"introspect should include search and status commands"
);
}
fn fetch_introspect_json() -> Value {
let mut cmd = base_cmd();
cmd.args(["introspect", "--json"]);
let stdout = String::from_utf8_lossy(&cmd.assert().success().get_output().stdout).into_owned();
serde_json::from_str(stdout.trim()).expect("valid introspect JSON")
}
fn find_command<'a>(json: &'a Value, name: &str) -> &'a Value {
let msg = format!("command {name} missing from introspect");
json["commands"]
.as_array()
.and_then(|cmds| cmds.iter().find(|c| c["name"] == name))
.expect(&msg)
}
fn find_arg<'a>(cmd: &'a Value, name: &str) -> &'a Value {
let msg = format!("arg {name} missing in command {}", cmd["name"]);
cmd["arguments"]
.as_array()
.and_then(|args| args.iter().find(|a| a["name"] == name))
.expect(&msg)
}
#[test]
fn introspect_commands_match_clap_subcommands() {
run_on_large_stack(|| {
let json = fetch_introspect_json();
let clap_cmd = Cli::command();
let clap_commands: HashSet<String> = clap_cmd
.get_subcommands()
.map(|c: &clap::Command| c.get_name().to_string())
.collect();
let introspect_commands: HashSet<String> = json["commands"]
.as_array()
.expect("commands array")
.iter()
.filter_map(|c| c["name"].as_str().map(|s| s.to_string()))
.collect();
assert_eq!(
clap_commands, introspect_commands,
"introspect should list exactly the Clap subcommands"
);
// Ensure no help/version pseudo-args leak into schemas
for cmd in json["commands"].as_array().unwrap() {
let args = cmd["arguments"].as_array().unwrap();
assert!(
!args
.iter()
.any(|a| a["name"] == "help" || a["name"] == "version"),
"help/version flags should be hidden in introspect"
);
}
});
}
#[test]
fn introspect_arguments_capture_types_defaults_and_repeatable() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let limit = find_arg(search, "limit");
assert_eq!(limit["value_type"], "integer");
assert_eq!(limit["default"], "0");
let offset = find_arg(search, "offset");
assert_eq!(offset["value_type"], "integer");
assert_eq!(offset["default"], "0");
let agent = find_arg(search, "agent");
assert_eq!(agent["repeatable"], true);
assert_eq!(agent["arg_type"], "option");
let workspace = find_arg(search, "workspace");
assert_eq!(workspace["repeatable"], true);
let robot_format = find_arg(search, "robot-format");
assert_eq!(robot_format["value_type"], "enum");
let formats = robot_format["enum_values"].as_array().unwrap();
let format_set: HashSet<_> = formats.iter().map(|v| v.as_str().unwrap()).collect();
assert!(
format_set.contains("json")
&& format_set.contains("jsonl")
&& format_set.contains("compact")
);
let data_dir = find_arg(search, "data-dir");
assert_eq!(data_dir["value_type"], "path");
let aggregate = find_arg(search, "aggregate");
assert_eq!(aggregate["repeatable"], true);
assert_eq!(aggregate["value_type"], "string");
let stale = find_arg(find_command(&json, "status"), "stale-threshold");
assert_eq!(stale["value_type"], "integer");
assert_eq!(stale["default"], "1800");
let view = find_command(&json, "view");
let path_arg = find_arg(view, "path");
assert_eq!(path_arg["value_type"], "path");
assert_eq!(path_arg["arg_type"], "positional");
// Repeatable watch-once paths (index command)
let index = find_command(&json, "index");
let watch_once = find_arg(index, "watch-once");
assert_eq!(watch_once["repeatable"], true);
assert_eq!(watch_once["value_type"], "path");
}
#[test]
fn introspect_sessions_command_exposes_workspace_current_and_limit() {
let json = fetch_introspect_json();
let sessions = find_command(&json, "sessions");
let workspace = find_arg(sessions, "workspace");
assert_eq!(workspace["value_type"], "path");
assert_eq!(workspace["arg_type"], "option");
let current = find_arg(sessions, "current");
assert_eq!(current["arg_type"], "flag");
let limit = find_arg(sessions, "limit");
assert_eq!(limit["value_type"], "integer");
let data_dir = find_arg(sessions, "data-dir");
assert_eq!(data_dir["value_type"], "path");
}
#[test]
fn diag_json_reports_paths_and_connectors() {
let mut cmd = base_cmd();
cmd.args([
"diag",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
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 diag JSON");
assert!(json["paths"]["data_dir"].is_string());
assert!(json["database"]["exists"].is_boolean());
assert!(json["index"]["exists"].is_boolean());
assert!(
json["connectors"].is_array(),
"diag should include connectors array"
);
let connector_names: HashSet<String> = json["connectors"]
.as_array()
.expect("connectors array")
.iter()
.filter_map(|entry| entry.get("name"))
.filter_map(|name| name.as_str())
.map(str::to_string)
.collect();
for expected in ["aider", "pi_agent", "claude_code"] {
assert!(
connector_names.contains(expected),
"diag connectors missing expected entry: {expected}"
);
}
}
#[test]
fn view_json_outputs_file_excerpt() {
// Use a small text file and ensure view returns JSON payload.
let mut cmd = base_cmd();
let path = "README.md";
cmd.args(["view", path, "--json", "-n", "1", "-C", "0"]);
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 view JSON");
assert_eq!(json["path"], path);
assert!(json["lines"].is_array());
}
#[test]
fn status_json_reports_staleness_flags() {
let mut cmd = base_cmd();
cmd.args([
"status",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
"--stale-threshold",
"1",
]);
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 status JSON");
let Some(status) = json.get("status").and_then(|v| v.as_object()) else {
// If status key is absent in this build/contract, skip further assertions.
return;
};
assert!(
status.get("db_exists").and_then(|v| v.as_bool()).is_some(),
"status should include db_exists boolean"
);
assert!(
status
.get("index_exists")
.and_then(|v| v.as_bool())
.is_some(),
"status should include index_exists boolean"
);
assert!(
status.get("stale").and_then(|v| v.as_bool()).is_some(),
"status should include stale boolean"
);
}
#[test]
fn stats_missing_db_returns_code_3() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"stats",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let assert = cmd.assert().failure();
let output = assert.get_output().clone();
assert_eq!(
output.status.code(),
Some(3),
"missing db should return exit code 3"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("missing-db") || stderr.contains("Database not found"),
"stderr should mention missing database"
);
}
#[test]
fn search_agent_filter_limits_hits() {
// Agent filter should restrict results to the chosen agent
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--agent",
"aider",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
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 JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
assert_eq!(json["count"].as_u64().unwrap_or(0), 0);
return;
}
for hit in hits {
assert_eq!(hit["agent"], "aider", "agent filter should be enforced");
}
}
#[test]
fn search_provider_alias_filters_like_agent() {
for alias_args in [
vec!["--provider", "aider"],
vec!["--tool", "aider"],
vec!["provider=aider"],
] {
let mut cmd = base_cmd();
cmd.args(["search", "", "--json", "--limit", "10"]);
cmd.args(alias_args);
cmd.args(["--data-dir", "tests/fixtures/search_demo_data"]);
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 JSON");
let hits = json["hits"].as_array().expect("hits array");
assert!(
!hits.is_empty(),
"fixture should contain aider hits: {json}"
);
for hit in hits {
assert_eq!(
hit["agent"], "aider",
"provider alias should filter by agent"
);
}
}
}
#[test]
fn search_offset_skips_results() {
// Offset should skip earlier hits while preserving order
let mut cmd_full = base_cmd();
cmd_full.args([
"search",
"hello",
"--json",
"--limit",
"3",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let full_bytes = cmd_full.assert().success().get_output().stdout.to_vec();
let full_stdout = String::from_utf8_lossy(&full_bytes);
let full_json: Value =
serde_json::from_str(full_stdout.trim()).expect("valid JSON for base search");
let full_hits = full_json["hits"].as_array().expect("hits array");
if full_hits.len() < 2 {
// dataset too small to assert offset meaningfully
return;
}
let mut cmd_offset = base_cmd();
cmd_offset.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--offset",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let offset_bytes = cmd_offset.assert().success().get_output().stdout.to_vec();
let offset_stdout = String::from_utf8_lossy(&offset_bytes);
let offset_json: Value =
serde_json::from_str(offset_stdout.trim()).expect("valid JSON for offset search");
let offset_hits = offset_json["hits"].as_array().expect("hits array");
assert_eq!(offset_hits.len(), 1, "limit should be applied after offset");
let offset_path = offset_hits[0]["source_path"].as_str().unwrap_or_default();
// Minimal guarantee: with offset applied we still get a hit (if data has >1),
// and the limit is honored. Dataset ordering/dedup may vary.
assert!(
!offset_path.is_empty(),
"offset result should still return a hit"
);
}
#[test]
fn robot_mode_auto_quiet_suppresses_info_logs() {
// rob.ctx.quiet: Robot mode (--json) should auto-suppress INFO logs on stderr
// This ensures AI agents get clean, parseable stdout without log noise
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stderr = String::from_utf8_lossy(&output.stderr);
// INFO logs should NOT appear in stderr when using --json
assert!(
!stderr.contains("INFO"),
"Robot mode should auto-suppress INFO logs. Got stderr: {stderr}"
);
// JSON output should still be valid on stdout
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON output");
assert!(json["hits"].is_array(), "Should have valid hits array");
}
#[test]
fn non_robot_mode_shows_info_logs() {
// Verify that non-robot mode DOES show INFO logs (baseline check)
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stderr = String::from_utf8_lossy(&output.stderr);
// INFO logs SHOULD appear in stderr when NOT using --json
assert!(
stderr.contains("INFO") || stderr.contains("search_start"),
"Non-robot mode should show INFO logs. Got stderr: {stderr}"
);
}
// ============================================================
// rob.ctx.fields: Field Selection Tests
// ============================================================
#[test]
fn fields_filters_to_requested_only() {
// rob.ctx.fields: --fields should filter hits to only requested fields
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--limit",
"1",
"--fields",
"source_path,line_number",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
return;
}
let hit = &hits[0];
// Should have only the requested fields
assert!(hit["source_path"].is_string(), "Should have source_path");
assert!(hit["line_number"].is_number(), "Should have line_number");
// Should NOT have other fields
assert!(hit["score"].is_null(), "Should NOT have score");
assert!(hit["agent"].is_null(), "Should NOT have agent");
assert!(hit["content"].is_null(), "Should NOT have content");
}
#[test]
fn fields_minimal_preset_expands() {
// rob.ctx.fields: 'minimal' preset should expand to source_path,line_number,agent
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--limit",
"1",
"--fields",
"minimal",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
return;
}
let hit = &hits[0];
// Minimal preset fields
assert!(hit["source_path"].is_string(), "Should have source_path");
assert!(hit["line_number"].is_number(), "Should have line_number");
assert!(hit["agent"].is_string(), "Should have agent");
// Should NOT have extra fields
assert!(hit["score"].is_null(), "Should NOT have score");
assert!(hit["content"].is_null(), "Should NOT have content");
}
#[test]
fn fields_summary_preset_expands() {
// rob.ctx.fields: 'summary' preset should expand to source_path,line_number,agent,title,score
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--limit",
"1",
"--fields",
"summary",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
return;
}
let hit = &hits[0];
// Summary preset fields
assert!(hit["source_path"].is_string(), "Should have source_path");
assert!(hit["line_number"].is_number(), "Should have line_number");
assert!(hit["agent"].is_string(), "Should have agent");
assert!(!hit["title"].is_null(), "Should have title");
assert!(hit["score"].is_number(), "Should have score");
// Should NOT have extra fields
assert!(hit["content"].is_null(), "Should NOT have content");
assert!(hit["snippet"].is_null(), "Should NOT have snippet");
}
#[test]
fn fields_works_with_jsonl_format() {
// rob.ctx.fields: Field selection should work with --robot-format jsonl
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--robot-format",
"jsonl",
"--limit",
"1",
"--fields",
"source_path,score",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
// JSONL: each line is a separate JSON object (hit)
for line in stdout.lines() {
let json: Value = serde_json::from_str(line).expect("valid JSON line");
// Skip _meta lines
if json.get("_meta").is_some() {
continue;
}
// Hit lines should only have requested fields
assert!(json["source_path"].is_string(), "Should have source_path");
assert!(json["score"].is_number(), "Should have score");
// Count fields (excluding null)
let obj = json.as_object().expect("object");
assert_eq!(obj.len(), 2, "Should have exactly 2 fields");
}
}
// ============================================================
// rob.ctx.trunc: Content Truncation Tests
// ============================================================
#[test]
fn max_content_length_truncates_long_content() {
// rob.ctx.trunc: --max-content-length should truncate content fields with ellipsis
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--limit",
"1",
"--max-content-length",
"5",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
return;
}
let hit = &hits[0];
// Content should be truncated with ellipsis
let content = hit["content"].as_str().expect("content string");
assert!(
content.ends_with("..."),
"Truncated content should end with ellipsis"
);
assert!(
content.len() <= 5,
"Content should be at most max_content_length"
);
// Should have _truncated indicator
assert!(
hit.get("content_truncated").is_some(),
"Should have content_truncated indicator"
);
}
#[test]
fn max_content_length_adds_truncated_indicator() {
// rob.ctx.trunc: Truncation adds _truncated indicator for each truncated field
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--max-content-length",
"3",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
return;
}
let hit = &hits[0];
// Both content and snippet should have truncated indicators
if hit["content"].is_string() {
assert!(
hit.get("content_truncated").is_some(),
"content_truncated indicator should exist when content is truncated"
);
}
if hit["snippet"].is_string() {
assert!(
hit.get("snippet_truncated").is_some(),
"snippet_truncated indicator should exist when snippet is truncated"
);
}
}
#[test]
fn max_content_length_preserves_short_content() {
// rob.ctx.trunc: Content shorter than limit should not be truncated
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--max-content-length",
"1000",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
return;
}
let hit = &hits[0];
// Should NOT have truncated indicators when content is short
assert!(
hit.get("content_truncated").is_none(),
"Short content should not have truncated indicator"
);
// Content should not end with ellipsis
if let Some(content) = hit["content"].as_str() {
assert!(
!content.ends_with("..."),
"Short content should not have ellipsis"
);
}
}
#[test]
fn max_content_length_works_with_fields() {
// rob.ctx.trunc: Truncation should work alongside field selection
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--limit",
"1",
"--max-content-length",
"5",
"--fields",
"content,snippet",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if hits.is_empty() {
return;
}
let hit = &hits[0];
// Should have requested fields
assert!(hit["content"].is_string(), "Should have content field");
// Should be truncated
let content = hit["content"].as_str().unwrap();
assert!(content.ends_with("..."), "Content should be truncated");
// Truncated indicator should be included even when fields are filtered
assert!(
hit.get("content_truncated").is_some(),
"Truncated indicator should be included"
);
}
// ============================================================
// rob.state.status: Status Command Tests
// ============================================================
#[test]
fn status_json_returns_health_info() -> Result<(), Box<dyn Error>> {
// rob.state.status: status command should return health information as JSON
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args(["status", "--json", "--data-dir", data_dir]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should have required top-level fields
assert!(json["healthy"].is_boolean(), "Should have healthy boolean");
assert!(json["index"].is_object(), "Should have index object");
assert!(json["database"].is_object(), "Should have database object");
assert!(json["pending"].is_object(), "Should have pending object");
assert!(json["semantic"].is_object(), "Should have semantic object");
// Database should exist in fixture
assert_eq!(
json["database"]["exists"],
Value::Bool(true),
"Database should exist"
);
assert!(
json["database"]["conversations"].as_i64().unwrap() > 0,
"Should have conversations"
);
assert!(
json["database"]["messages"].as_i64().unwrap() > 0,
"Should have messages"
);
Ok(())
}
#[test]
fn status_json_reports_stale_threshold() -> Result<(), Box<dyn Error>> {
// rob.state.status: status should include stale threshold info
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args([
"status",
"--json",
"--stale-threshold",
"60",
"--data-dir",
data_dir,
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should have stale threshold
assert_eq!(
json["index"]["stale_threshold_seconds"],
Value::Number(60.into()),
"Stale threshold should match argument"
);
Ok(())
}
#[test]
fn status_missing_db_reports_not_initialized() {
// rob.state.status: brand-new data dir should surface initialization guidance
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"status",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(json["status"], Value::String("not_initialized".to_string()));
assert_eq!(json["initialized"], Value::Bool(false));
assert_eq!(
json["database"]["exists"],
Value::Bool(false),
"Database should not exist"
);
assert_eq!(
json["healthy"],
Value::Bool(false),
"Should not be healthy without db"
);
assert_eq!(json["index"]["exists"], Value::Bool(false));
assert_eq!(json["index"]["stale"], Value::Bool(false));
assert!(
!tmp.path().join("index").exists(),
"status should not create an empty index dir while inspecting a fresh install"
);
assert!(
json["explanation"]
.as_str()
.unwrap_or("")
.contains("fresh install"),
"status should explain that this is an expected cold-start state: {json}"
);
assert!(
json["recommended_action"]
.as_str()
.unwrap_or("")
.contains("cass index --full"),
"Should recommend the first index run"
);
assert_not_initialized_recommended_commands(&json, tmp.path());
assert_eq!(
json["semantic"]["status"],
Value::String("not_initialized".to_string())
);
assert!(
json["semantic"]["summary"]
.as_str()
.unwrap_or("")
.contains("optional"),
"fresh installs should not surface semantic model absence as a failure: {json}"
);
assert_eq!(json["semantic"]["embedder_id"], Value::Null);
assert_eq!(json["semantic"]["vector_index_path"], Value::Null);
assert_eq!(json["semantic"]["model_dir"], Value::Null);
assert_eq!(json["semantic"]["hnsw_path"], Value::Null);
}
#[test]
fn status_empty_index_dir_without_meta_still_reports_not_initialized() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(coding_agent_search::search::tantivy::expected_index_dir(
tmp.path(),
))
.unwrap();
let mut cmd = base_cmd();
cmd.args([
"status",
"--json",
"--data-dir",
tmp.path().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 JSON");
assert_eq!(json["status"], Value::String("not_initialized".to_string()));
assert_eq!(json["initialized"], Value::Bool(false));
assert_eq!(json["index"]["exists"], Value::Bool(false));
assert_eq!(json["index"]["stale"], Value::Bool(false));
assert!(
json["recommended_action"]
.as_str()
.unwrap_or("")
.contains("cass index --full"),
"empty leftover index dirs should not masquerade as a usable index: {json}"
);
}
#[test]
fn health_missing_db_reports_not_initialized() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"health",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let assert = cmd.assert().failure();
let output = assert.get_output();
assert_eq!(output.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(
output.stderr.is_empty(),
"health --json should not emit a second structured error once stdout already contains the full payload"
);
assert_eq!(json["status"], Value::String("not_initialized".to_string()));
assert_eq!(json["initialized"], Value::Bool(false));
assert_eq!(json["healthy"], Value::Bool(false));
assert_eq!(json["state"]["index"]["exists"], Value::Bool(false));
assert_eq!(json["state"]["index"]["stale"], Value::Bool(false));
assert!(
!tmp.path().join("index").exists(),
"health should not create an empty index dir while probing a fresh install"
);
assert!(
json["explanation"]
.as_str()
.unwrap_or("")
.contains("fresh install"),
"health should explain the cold-start state: {json}"
);
assert!(
json["recommended_action"]
.as_str()
.unwrap_or("")
.contains("cass index --full"),
"health should recommend the initial index run: {json}"
);
assert_not_initialized_recommended_commands(&json, tmp.path());
assert!(
json["errors"]
.as_array()
.map(|errors| errors
.iter()
.any(|entry| entry.as_str() == Some("database not initialized yet")))
.unwrap_or(false),
"health should distinguish not-initialized from broken: {json}"
);
assert_eq!(
json["state"]["semantic"]["status"],
Value::String("not_initialized".to_string())
);
assert_eq!(json["state"]["semantic"]["embedder_id"], Value::Null);
assert_eq!(json["state"]["semantic"]["vector_index_path"], Value::Null);
assert_eq!(json["state"]["semantic"]["model_dir"], Value::Null);
assert_eq!(json["state"]["semantic"]["hnsw_path"], Value::Null);
}
#[test]
fn doctor_not_initialized_ignores_active_lock_for_other_db() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path();
let db_path = data_dir.join("agent_search.db");
let other_db_path = data_dir.join("other-agent-search.db");
let lock_path = data_dir.join("index-run.lock");
let mut lock_file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
lock_file.try_lock_exclusive().unwrap();
writeln!(
lock_file,
"pid={}
started_at_ms={}
db_path={}
mode=index
job_kind=lexical_refresh
phase=rebuilding",
std::process::id(),
1_733_001_222_000_i64,
other_db_path.display()
)
.unwrap();
lock_file.flush().unwrap();
let mut cmd = base_cmd();
cmd.args([
"doctor",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
"--db",
db_path.to_str().unwrap(),
]);
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 JSON");
assert_eq!(
json["operation_state"]["active_index_maintenance"],
Value::Bool(false)
);
assert_eq!(
json["operation_state"]["active_rebuild"],
Value::Bool(false)
);
assert_eq!(
json["operation_state"]["mutating_doctor_allowed"],
Value::Bool(true)
);
assert_eq!(
json["operation_state"]["owners"][0]["db_path_matches_requested"],
Value::Bool(false)
);
assert!(
!json["recommended_action"]
.as_str()
.unwrap_or_default()
.contains("wait for cass status"),
"irrelevant DB lock should not make doctor recommend waiting: {json}"
);
}
#[test]
fn doctor_missing_data_dir_reports_not_initialized_without_failure() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("fresh-cass-home");
let mut cmd = base_cmd();
cmd.args(["doctor", "--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 JSON");
assert!(
output.stderr.is_empty(),
"doctor --json on a fresh install should explain initialization state without emitting an error: {stdout}"
);
assert_eq!(json["status"], Value::String("not_initialized".to_string()));
assert_eq!(json["initialized"], Value::Bool(false));
assert_eq!(json["healthy"], Value::Bool(false));
assert_eq!(json["failures"], Value::from(0));
assert!(
json["explanation"]
.as_str()
.unwrap_or("")
.contains("fresh install"),
"doctor should explain the cold-start state: {json}"
);
assert!(
json["recommended_action"]
.as_str()
.unwrap_or("")
.contains("cass index --full"),
"doctor should point users at the initial index run: {json}"
);
assert!(
json["checks"]
.as_array()
.map(|checks| checks.iter().any(|check| {
check["name"] == Value::String("database".to_string())
&& check["status"] == Value::String("warn".to_string())
}))
.unwrap_or(false),
"doctor should classify missing database as informational on fresh installs: {json}"
);
assert!(
json["checks"]
.as_array()
.map(|checks| checks.iter().any(|check| {
check["name"] == Value::String("index".to_string())
&& check["status"] == Value::String("warn".to_string())
}))
.unwrap_or(false),
"doctor should classify missing index as informational on fresh installs: {json}"
);
}
#[test]
fn search_missing_index_reports_current_rebuild_in_progress() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path();
let db_path = data_dir.join("agent_search.db");
let lock_path = data_dir.join("index-run.lock");
let mut lock_file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
lock_file.try_lock_exclusive().unwrap();
writeln!(
lock_file,
"pid={}
started_at_ms={}
db_path={}
mode=index
job_kind=lexical_refresh
phase=rebuilding",
std::process::id(),
1_733_001_333_000_i64,
db_path.display()
)
.unwrap();
lock_file.flush().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"auth",
"--json",
"--data-dir",
data_dir.to_str().unwrap(),
]);
let assert = cmd.assert().failure();
let output = assert.get_output();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let json: Value = serde_json::from_str(stderr.trim()).expect("valid JSON");
assert_eq!(
json["error"]["kind"],
Value::String("missing-index".to_string())
);
assert!(
json["error"]["message"]
.as_str()
.unwrap_or("")
.contains("already building the initial search index"),
"search should explain that the first index run is already in progress: {json}"
);
assert!(
json["error"]["hint"]
.as_str()
.unwrap_or("")
.contains("cass status --json"),
"search should point callers at status while waiting for the initial index build: {json}"
);
}
#[test]
fn search_missing_index_explains_initialization_required() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"auth",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
let assert = cmd.assert().failure();
let output = assert.get_output();
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8_lossy(&output.stderr);
let json: Value = serde_json::from_str(stderr.trim()).expect("valid JSON");
assert!(
!tmp.path().join("index").exists(),
"search should not create an empty index dir when the archive is not initialized"
);
assert_eq!(
json["error"]["kind"],
Value::String("missing-index".to_string())
);
assert!(
json["error"]["message"]
.as_str()
.unwrap_or("")
.contains("not been initialized"),
"search should explain that the data dir needs first-run indexing: {json}"
);
assert!(
json["error"]["hint"]
.as_str()
.unwrap_or("")
.contains("cass index --full"),
"search should tell the user exactly how to initialize the archive: {json}"
);
}
#[test]
fn status_json_reports_open_error_for_unopenable_db_path() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path();
fs::create_dir_all(data_dir.join("index").join("v4")).unwrap();
fs::create_dir_all(data_dir.join("agent_search.db")).unwrap();
let mut cmd = base_cmd();
cmd.args(["status", "--json", "--data-dir"])
.arg(data_dir)
.timeout(std::time::Duration::from_secs(10));
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"status should succeed even when the db path is unopenable"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(json["healthy"], Value::Bool(false));
assert_eq!(json["status"], Value::String("degraded".to_string()));
assert_eq!(json["database"]["exists"], Value::Bool(true));
assert_eq!(json["database"]["opened"], Value::Bool(false));
assert_ne!(
json["semantic"]["availability"],
Value::String("load_failed".to_string())
);
assert!(
!json["semantic"]["summary"]
.as_str()
.unwrap_or("")
.contains("asset inspection failed"),
"status should preserve the semantic root cause instead of collapsing to a generic asset failure: {json}"
);
assert!(
json["database"]["open_error"]
.as_str()
.unwrap_or("")
.contains("Failed to open")
|| json["database"]["open_error"]
.as_str()
.unwrap_or("")
.contains("open"),
"status should surface the open failure: {json}"
);
}
#[test]
fn health_json_reports_open_error_for_unopenable_db_path() {
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path();
fs::create_dir_all(data_dir.join("index").join("v4")).unwrap();
fs::create_dir_all(data_dir.join("agent_search.db")).unwrap();
let mut cmd = base_cmd();
cmd.args(["health", "--json", "--data-dir"])
.arg(data_dir)
.timeout(std::time::Duration::from_secs(10));
let output = cmd.output().unwrap();
assert!(
!output.status.success(),
"health should fail when the db exists but cannot be opened"
);
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(json["healthy"], Value::Bool(false));
assert_eq!(json["status"], Value::String("degraded".to_string()));
assert_eq!(json["db"]["exists"], Value::Bool(true));
assert_eq!(json["db"]["opened"], Value::Bool(false));
assert_eq!(
json["db"]["open_skipped"],
Value::Bool(false),
"top-level health db MUST report open_skipped=false when probe_state_db ran: {json}"
);
assert!(
json["db"]["open_error"]
.as_str()
.unwrap_or("")
.contains("Failed to open")
|| json["db"]["open_error"]
.as_str()
.unwrap_or("")
.contains("open"),
"health should surface the open failure: {json}"
);
assert_ne!(
json["state"]["semantic"]["availability"],
Value::String("load_failed".to_string())
);
assert!(
!json["state"]["semantic"]["summary"]
.as_str()
.unwrap_or("")
.contains("asset inspection failed"),
"health should preserve the semantic root cause instead of collapsing to a generic asset failure: {json}"
);
}
#[test]
fn status_human_readable_output() -> Result<(), Box<dyn Error>> {
// rob.state.status: status without --json should produce human-readable output
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args(["status", "--data-dir", data_dir]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should contain human-readable sections
assert!(stdout.contains("CASS Status"), "Should have status header");
assert!(stdout.contains("Database"), "Should have database section");
assert!(stdout.contains("Semantic"), "Should have semantic section");
assert!(
stdout.contains("Conversations"),
"Should show conversation count"
);
Ok(())
}
// ============================================================
// rob.flow.agg: Aggregation Mode Tests
// ============================================================
#[test]
fn aggregate_single_field_returns_buckets() {
// rob.flow.agg: --aggregate agent should return agent buckets
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--aggregate",
"agent",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should have aggregations object
assert!(
json["aggregations"].is_object(),
"Should have aggregations object"
);
let aggs = &json["aggregations"];
// Should have agent aggregation
assert!(aggs["agent"].is_object(), "Should have agent aggregation");
let agent_agg = &aggs["agent"];
assert!(
agent_agg["buckets"].is_array(),
"Agent aggregation should have buckets"
);
// Each bucket should have key and count
let buckets = agent_agg["buckets"].as_array().unwrap();
if !buckets.is_empty() {
let first = &buckets[0];
assert!(first["key"].is_string(), "Bucket should have key");
assert!(first["count"].is_number(), "Bucket should have count");
}
// Should have other_count
assert!(
agent_agg["other_count"].is_number(),
"Should have other_count"
);
}
#[test]
fn aggregate_multiple_fields_returns_all() {
// rob.flow.agg: --aggregate agent,workspace returns both aggregations
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--aggregate",
"agent,workspace",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let aggs = &json["aggregations"];
assert!(aggs["agent"].is_object(), "Should have agent aggregation");
assert!(
aggs["workspace"].is_object(),
"Should have workspace aggregation"
);
}
#[test]
fn aggregate_includes_total_matches() {
// rob.flow.agg: Aggregation response includes total_matches
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--aggregate",
"agent",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should have total_matches field
assert!(
json["total_matches"].is_number(),
"Should have total_matches field"
);
let returned_hits = json["hits"].as_array().map(|hits| hits.len()).unwrap_or(0);
assert!(
json["total_matches"].as_u64().unwrap_or(0) >= returned_hits as u64,
"total_matches should be at least the number of returned hits"
);
}
#[test]
fn aggregate_with_limit_returns_both_hits_and_aggs() {
// rob.flow.agg: --aggregate with --limit returns both aggregations and hits
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--aggregate",
"agent",
"--limit",
"2",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should have aggregations
assert!(
json["aggregations"]["agent"].is_object(),
"Should have aggregations"
);
// Should have hits (with limit applied)
let hits = json["hits"].as_array().expect("hits array");
assert!(
hits.len() <= 2,
"Hits should respect --limit even with aggregation"
);
}
#[test]
fn aggregate_match_type_returns_exact_wildcard_buckets() {
// rob.flow.agg: --aggregate match_type returns match type distribution
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--aggregate",
"match_type",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should have match_type aggregation
assert!(
json["aggregations"]["match_type"].is_object(),
"Should have match_type aggregation"
);
let buckets = json["aggregations"]["match_type"]["buckets"]
.as_array()
.expect("buckets array");
// At least one bucket should exist (exact, wildcard, or fuzzy)
if !buckets.is_empty() {
let keys: Vec<&str> = buckets.iter().filter_map(|b| b["key"].as_str()).collect();
// Keys should be lowercase match types
for key in &keys {
assert!(
["exact", "wildcard", "fuzzy", "recent"].contains(key),
"Match type key '{}' should be valid",
key
);
}
}
}
#[test]
fn aggregate_empty_query_returns_aggs() {
// rob.flow.agg: Empty query with aggregation returns all-document aggregations
let mut cmd = base_cmd();
cmd.args([
"search",
"",
"--json",
"--aggregate",
"agent",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should have aggregations even with empty query
assert!(
json["aggregations"]["agent"].is_object(),
"Should have agent aggregation for empty query"
);
}
#[test]
fn aggregate_preserves_offset_when_not_aggregating() {
// Verify that regular offset functionality is not broken by aggregation code
// This is a regression test for the offset=0 bug fix
let mut cmd_no_agg = base_cmd();
cmd_no_agg.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--offset",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let output = cmd_no_agg.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Should NOT have aggregations field (not requested)
assert!(
json.get("aggregations").is_none()
|| json["aggregations"]
.as_object()
.is_none_or(|o| o.is_empty()),
"Should not have aggregations when not requested"
);
// Hits should be present (offset applied)
let hits = json["hits"].as_array().expect("hits array");
assert!(hits.len() <= 1, "Limit should be respected");
}
// ============================================================
// rob.api.caps: Capabilities Introspection Tests
// ============================================================
#[test]
fn capabilities_json_returns_valid_structure() {
// rob.api.caps: capabilities --json should return valid JSON with required fields
let mut cmd = base_cmd();
cmd.args(["capabilities", "--json"]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
// Required top-level fields
assert!(
json["crate_version"].is_string(),
"Should have crate_version"
);
assert!(json["api_version"].is_number(), "Should have api_version");
assert!(
json["contract_version"].is_string(),
"Should have contract_version"
);
assert!(json["features"].is_array(), "Should have features array");
assert!(
json["connectors"].is_array(),
"Should have connectors array"
);
assert!(json["limits"].is_object(), "Should have limits object");
}
#[test]
fn capabilities_json_includes_expected_features() {
// rob.api.caps: capabilities should list all expected features
let mut cmd = base_cmd();
cmd.args(["capabilities", "--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 JSON");
let features = json["features"].as_array().expect("features array");
let feature_list: Vec<&str> = features.iter().filter_map(|v| v.as_str()).collect();
// Check for expected features
assert!(
feature_list.contains(&"json_output"),
"Should have json_output feature"
);
assert!(
feature_list.contains(&"aggregations"),
"Should have aggregations feature"
);
assert!(
feature_list.contains(&"field_selection"),
"Should have field_selection feature"
);
assert!(
feature_list.contains(&"time_filters"),
"Should have time_filters feature"
);
for feature in [
"doctor_v2_robot_contract",
"doctor_v2_response_schemas",
"doctor_v2_redacted_examples",
"doctor_v2_fingerprint_repairs",
"doctor_archive_first_safety",
] {
assert!(
feature_list.contains(&feature),
"capabilities should advertise {feature}"
);
}
}
#[test]
fn capabilities_json_includes_connectors() {
// rob.api.caps: capabilities should list supported agent connectors
let mut cmd = base_cmd();
cmd.args(["capabilities", "--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 JSON");
let connectors = json["connectors"].as_array().expect("connectors array");
let connector_list: Vec<&str> = connectors.iter().filter_map(|v| v.as_str()).collect();
// Check for expected connectors
assert!(connector_list.contains(&"codex"), "Should support codex");
assert!(
connector_list.contains(&"claude_code"),
"Should support claude_code"
);
assert!(
connector_list.len() >= 4,
"Should have at least 4 connectors"
);
}
#[test]
fn capabilities_connectors_cover_indexer_registry() {
// Prevent drift between the indexer connector registry and the capabilities contract.
let mut cmd = base_cmd();
cmd.args(["capabilities", "--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 JSON");
let connectors = json["connectors"].as_array().expect("connectors array");
let connector_list: Vec<String> = connectors
.iter()
.filter_map(|v| v.as_str())
.map(str::to_string)
.collect();
let connector_set: HashSet<String> = connector_list.iter().cloned().collect();
assert_eq!(
connector_set.len(),
connector_list.len(),
"capabilities connector list should not contain duplicates"
);
let expected_from_registry: Vec<String> =
coding_agent_search::indexer::get_connector_factories()
.into_iter()
.map(|(slug, _)| match slug {
"claude" => "claude_code".to_string(),
other => other.to_string(),
})
.collect();
for expected in expected_from_registry {
assert!(
connector_set.contains(&expected),
"capabilities connector list missing registry connector: {expected}"
);
}
}
#[test]
fn capabilities_json_includes_limits() {
// rob.api.caps: capabilities should include system limits
let mut cmd = base_cmd();
cmd.args(["capabilities", "--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 JSON");
let limits = &json["limits"];
assert!(limits["max_limit"].is_number(), "Should have max_limit");
assert!(
limits["max_content_length"].is_number(),
"Should have max_content_length"
);
assert!(limits["max_fields"].is_number(), "Should have max_fields");
assert!(
limits["max_agg_buckets"].is_number(),
"Should have max_agg_buckets"
);
// Sanity check values
let max_limit = limits["max_limit"].as_u64().expect("max_limit");
assert!(
max_limit == 0 || max_limit >= 1000,
"max_limit should be unlimited (0) or reasonably high"
);
}
#[test]
fn capabilities_human_output_contains_sections() {
// rob.api.caps: capabilities without --json should produce human-readable output
let mut cmd = base_cmd();
cmd.args(["capabilities"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should contain human-readable sections
assert!(
stdout.contains("CASS Capabilities"),
"Should have capabilities header"
);
assert!(stdout.contains("Version:"), "Should show version");
assert!(stdout.contains("Features:"), "Should have features section");
assert!(
stdout.contains("Connectors:"),
"Should have connectors section"
);
assert!(stdout.contains("Limits:"), "Should have limits section");
}
#[test]
fn capabilities_version_matches_crate() {
// rob.api.caps: capabilities version should match crate version
let mut cmd = base_cmd();
cmd.args(["capabilities", "--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 JSON");
let version = json["crate_version"].as_str().expect("crate_version");
// Should be a valid semver version
assert!(
version.chars().filter(|c| *c == '.').count() == 2,
"Version should be semver format (x.y.z)"
);
}
#[test]
fn search_json_includes_suggestions_for_typos() {
// rob.query.suggest: Zero-hit search should return suggestions
// Fixture data has "gemini" agent. "gemenii" should trigger typo suggestion.
let mut cmd = base_cmd();
cmd.args([
"search",
"gemenii",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
assert!(hits.is_empty(), "Should have 0 hits for typo");
let suggestions = json["suggestions"].as_array().expect("suggestions array");
assert!(!suggestions.is_empty(), "Should have suggestions");
let found = suggestions
.iter()
.any(|s| s["kind"] == "spelling_fix" && s["suggested_query"].as_str() == Some("gemini"));
assert!(found, "Should suggest 'gemini' for 'gemenii'");
}
// =============================================================================
// CLI Argument Normalization Tests (tst.cli.norm)
// Tests for forgiving CLI that auto-corrects minor syntax issues
// =============================================================================
/// Single-dash long flags should be auto-corrected to double-dash
/// e.g., `-robot` → `--robot`
#[test]
fn normalize_single_dash_to_double_dash() {
// Test that -robot-help still works (should be normalized to --robot-help)
let mut cmd = base_cmd();
cmd.arg("-robot-help");
// Should succeed because -robot-help is normalized to --robot-help
cmd.assert().success().stdout(contains("cass --robot-help"));
}
/// Case normalization for flags: --Robot → --robot
#[test]
fn normalize_flag_case() {
let mut cmd = base_cmd();
cmd.args(["--Robot-help"]);
// Should succeed because --Robot-help is normalized to --robot-help
cmd.assert().success().stdout(contains("cass --robot-help"));
}
/// Subcommand aliases should work: find → search
#[test]
fn subcommand_alias_find_to_search() {
let mut cmd = base_cmd();
cmd.args([
"find",
"test query",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
// 'find' should be normalized to 'search'
// May succeed or fail based on search results, but should not fail on parsing
let assert = cmd.assert();
// If command is recognized, it should either succeed or fail with a search-related error
// not a "command not found" error
assert.code(predicate::in_iter(vec![0, 1, 2, 3]));
}
/// Subcommand alias: query → search
#[test]
fn subcommand_alias_query_to_search() {
let mut cmd = base_cmd();
cmd.args([
"query",
"test",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert();
assert.code(predicate::in_iter(vec![0, 1, 2, 3]));
}
/// Subcommand alias: ls → stats
#[test]
fn subcommand_alias_ls_to_stats() -> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args(["ls", "--json", "--data-dir", data_dir]);
// 'ls' should be normalized to 'stats'
let assert = cmd.assert();
assert.code(predicate::in_iter(vec![0, 1, 2, 3]));
Ok(())
}
/// Subcommand alias: docs → robot-docs
#[test]
fn subcommand_alias_docs_to_robot_docs() {
let mut cmd = base_cmd();
cmd.args(["docs", "commands"]);
// 'docs' should be normalized to 'robot-docs'
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output robot-docs content
assert!(
stdout.contains("search") || stdout.contains("cass"),
"docs alias should produce robot-docs output"
);
}
/// Flag-as-subcommand: --robot-docs → robot-docs
#[test]
fn flag_as_subcommand_robot_docs() {
let mut cmd = base_cmd();
cmd.args(["--robot-docs", "commands"]);
// --robot-docs should be treated as subcommand
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("search") || stdout.contains("cass"),
"--robot-docs should work like robot-docs subcommand"
);
}
#[test]
fn robot_docs_topic_shorthands_route_to_robot_docs() {
for (alias, expected) in [
("commands", "commands:"),
("command", "commands:"),
("cmds", "commands:"),
("schemas", "schemas:"),
("examples", "examples:"),
("example", "examples:"),
("env", "env:"),
("paths", "paths:"),
("contracts", "contracts:"),
("exit-codes", "exit-codes:"),
("exit_codes", "exit-codes:"),
("quickstart", "guide:"),
("quick-start", "guide:"),
] {
let mut cmd = base_cmd();
cmd.args([alias, "--json", "--color=never"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(expected),
"{alias} should route to robot-docs topic {expected}, got: {stdout}"
);
assert!(
serde_json::from_str::<Value>(stdout.trim()).is_err(),
"{alias} should emit robot-docs text, not search JSON"
);
assert!(
!stdout.contains('\u{1b}'),
"{alias} should honor hoisted --color=never"
);
}
let mut cmd = base_cmd();
cmd.args(["--json", "schemas", "--color=never"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("schemas:"));
assert!(
serde_json::from_str::<Value>(stdout.trim()).is_err(),
"leading --json schemas should emit robot-docs text, not search JSON"
);
}
#[test]
fn guide_planner_and_robot_docs_guide_are_unambiguous() {
let mut planner = base_cmd();
planner.args(["guide", "--json", "--color=never"]);
let planner_output = planner.assert().success().get_output().clone();
let planner_stdout = String::from_utf8_lossy(&planner_output.stdout);
let planner_json: Value = serde_json::from_str(planner_stdout.trim())
.expect("bare cass guide should emit the guided-operations planner JSON");
assert_eq!(
planner_json["schema_version"], "cass.guide.plan.v1",
"bare cass guide must remain the dedicated planner"
);
assert!(
!planner_stdout.contains('\u{1b}'),
"guide planner should honor --color=never"
);
let mut docs = base_cmd();
docs.args(["robot-docs", "guide", "--color=never"]);
let docs_output = docs.assert().success().get_output().clone();
let docs_stdout = String::from_utf8_lossy(&docs_output.stdout);
assert!(
docs_stdout.contains("guide:"),
"explicit cass robot-docs guide must retain the documentation topic"
);
assert!(
serde_json::from_str::<Value>(docs_stdout.trim()).is_err(),
"robot-docs guide should remain deterministic documentation text"
);
assert!(
!docs_stdout.contains('\u{1b}'),
"robot-docs guide should honor --color=never"
);
}
#[test]
fn structured_help_requests_route_to_robot_docs() {
for (args, expected) in [
(vec!["help", "--json", "--color=never"], "guide:"),
(
vec!["help", "commands", "--json", "--color=never"],
"commands:",
),
(
vec!["help", "--json", "commands", "--color=never"],
"commands:",
),
(
vec!["--json", "help", "schemas", "--color=never"],
"schemas:",
),
(
vec!["help", "doctor", "--output", "json", "--color=never"],
"# cass doctor",
),
(
vec!["help", "sources", "--output", "json", "--color=never"],
"sources:",
),
(
vec!["help", "--output", "json", "doctor", "--color=never"],
"# cass doctor",
),
(
vec!["sources", "--help", "--json", "--color=never"],
"sources:",
),
(
vec!["search", "--help", "--json", "--color=never"],
"commands:",
),
(
vec!["search", "--json", "--help", "--color=never"],
"commands:",
),
(
vec!["--json", "search", "--help", "--color=never"],
"commands:",
),
(vec!["--help", "--json", "--color=never"], "guide:"),
] {
let mut cmd = base_cmd();
cmd.args(args);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(expected),
"structured help should route to {expected}, got: {stdout}"
);
assert!(
serde_json::from_str::<Value>(stdout.trim()).is_err(),
"structured help recovery should emit robot-docs text, not JSON"
);
assert!(
!stdout.contains('\u{1b}'),
"structured help should honor hoisted --color=never"
);
}
}
/// Correction notices appear on stderr when auto-correcting
#[test]
fn correction_notice_appears_on_stderr() {
let mut cmd = base_cmd();
// Use a combination that triggers auto-correction
cmd.args(["-robot-help"]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stderr = String::from_utf8_lossy(&output.stderr);
// Should have some correction notice on stderr
// Note: The exact format may vary, but there should be some indication of correction
assert!(
stderr.contains("Auto-corrected")
|| stderr.contains("syntax_correction")
|| stderr.contains("→")
|| stderr.is_empty(), // Or stderr might be empty if no correction was needed
"Correction notice should appear on stderr when args are normalized"
);
}
/// Global flags can appear after subcommand (should be hoisted)
#[test]
fn global_flags_hoisted_from_after_subcommand() {
let mut cmd = base_cmd();
// Put --color=never after robot-docs (should be hoisted to front)
cmd.args(["robot-docs", "commands", "--color=never"]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should work and not contain ANSI codes
assert!(
!stdout.contains('\u{1b}'),
"Global flag --color=never should be respected even after subcommand"
);
}
/// Error messages include contextual examples in JSON format
#[test]
fn error_messages_include_contextual_examples() {
let mut cmd = base_cmd();
// Invalid command that should trigger rich error
cmd.args(["--json", "foobar", "invalid"]);
let assert = cmd.assert().failure();
let output = assert.get_output();
let stderr = String::from_utf8_lossy(&output.stderr);
// Should have examples in the error output
assert!(
stderr.contains("examples") || stderr.contains("cass"),
"Error should include examples to help the agent"
);
}
#[test]
fn parse_error_intent_ignores_binary_path() {
let mut cmd = base_cmd();
cmd.args([
"view",
"README.md",
"--definitely-not-a-real-flag",
"--json",
]);
let output = cmd.assert().failure().code(2).get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
let json: Value = serde_json::from_str(stderr.trim()).expect("valid JSON error");
let examples = json["examples"].as_array().expect("examples array");
assert!(
examples
.iter()
.filter_map(Value::as_str)
.any(|example| example.starts_with("cass view ")),
"view parse errors should show view examples, got: {examples:?}"
);
assert!(
!examples
.iter()
.filter_map(Value::as_str)
.any(|example| example.starts_with("cass sessions ")),
"binary path should not make view errors look like session-listing intent"
);
}
/// Combining multiple normalizations works correctly
#[test]
fn multiple_normalizations_combined() {
// Test: -Robot-help (single dash + wrong case)
let mut cmd = base_cmd();
cmd.args(["-Robot-help"]);
// Should normalize to --robot-help
cmd.assert().success().stdout(contains("cass --robot-help"));
}
#[test]
fn top_level_subcommand_typo_recovers_before_implicit_search() {
let mut cmd = base_cmd();
cmd.args(["searh", "dry run sentinel", "--robot", "--dry-run"]);
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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true));
assert_eq!(json["valid"].as_bool(), Some(true));
assert_eq!(json["query"].as_str(), Some("dry run sentinel"));
}
#[test]
fn implicit_robot_search_folds_unquoted_query_words() {
let mut cmd = base_cmd();
cmd.args([
"authentication",
"error",
"--robot",
"--dry-run",
"provider=codex",
"max_results=5",
]);
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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true));
assert_eq!(json["valid"].as_bool(), Some(true));
assert_eq!(json["query"].as_str(), Some("authentication error"));
}
#[test]
fn implicit_robot_pack_query_uses_pack_when_pack_only_flags_present() {
let mut cmd = base_cmd();
cmd.args([
"auth",
"failed",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
"--limit",
"1",
"--max-evidence",
"1",
"--max-sessions",
"1",
]);
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 pack JSON");
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v1"));
assert_eq!(json["query"]["text"].as_str(), Some("auth failed"));
assert_eq!(json["limits"]["max_evidence"].as_u64(), Some(1));
assert_eq!(json["limits"]["max_sessions"].as_u64(), Some(1));
}
#[test]
fn timed_out_robot_pack_preserves_evidence_and_names_shed_work() -> Result<(), Box<dyn Error>> {
let data_dir = isolated_search_demo_data()?;
let output = base_cmd()
.env("CASS_PACK_BUDGET_MS", "250")
.env("CASS_TEST_PACK_SLOW_MS", "350")
.args([
"pack",
"hello",
"--json",
"--mode",
"lexical",
"--explain-selection",
"--data-dir",
data_dir.path().to_str().ok_or("non-utf8 data dir")?,
])
.output()?;
if !output.status.success() {
return Err(format!(
"timed-out pack failed: status={:?}; stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
)
.into());
}
let payload: Value = serde_json::from_slice(&output.stdout)?;
let budget = &payload["budget"];
let skipped = budget["skipped_sections"]
.as_array()
.ok_or("pack skipped_sections is not an array")?;
if budget["timed_out"] != true {
return Err(format!("pack timeout was not reported: {budget}").into());
}
if !payload["evidence"]
.as_array()
.is_some_and(|evidence| !evidence.is_empty())
{
return Err("pack timeout discarded completed evidence".into());
}
if !skipped
.iter()
.any(|section| section == "selection_explanations")
{
return Err(format!("pack timeout omitted its shed selection work: {budget}").into());
}
if payload["_meta"]["partial"] != true
|| budget["recommended_next_probe"] != "cass health --json"
{
return Err(format!("pack partial metadata is inconsistent: {payload}").into());
}
Ok(())
}
#[test]
fn explicit_search_pack_only_flags_run_pack_in_robot_mode() {
for command in ["search", "find"] {
let mut cmd = base_cmd();
cmd.args([
command,
"auth",
"failed",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
"--limit",
"1",
"--max-evidence",
"1",
"--max-sessions",
"1",
]);
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 pack JSON");
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v1"));
assert_eq!(json["query"]["text"].as_str(), Some("auth failed"));
assert_eq!(json["limits"]["max_evidence"].as_u64(), Some(1));
assert_eq!(json["limits"]["max_sessions"].as_u64(), Some(1));
}
}
#[test]
fn explicit_search_folds_unquoted_query_words_before_assignments() {
let mut cmd = base_cmd();
cmd.args([
"search",
"authentication",
"error",
"--robot",
"--dry-run",
"provider=codex",
"max_results=5",
]);
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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true));
assert_eq!(json["valid"].as_bool(), Some(true));
assert_eq!(json["query"].as_str(), Some("authentication error"));
}
#[test]
fn explicit_search_recovers_bare_filter_pairs_after_query_words() {
let mut cmd = base_cmd();
cmd.args([
"search",
"authentication",
"error",
"provider",
"codex",
"limit",
"5",
"last",
"7d",
"--robot",
"--dry-run",
]);
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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true));
assert_eq!(json["valid"].as_bool(), Some(true));
assert_eq!(json["query"].as_str(), Some("authentication error"));
let filters = &json["explanation"]["filters_summary"];
assert_eq!(filters["agent_count"].as_u64(), Some(1));
assert_eq!(filters["has_time_filter"].as_bool(), Some(true));
}
#[test]
fn explicit_search_recovers_query_after_leading_filters() {
let mut cmd = base_cmd();
cmd.args([
"search",
"--agent",
"codex",
"--limit",
"5",
"authentication",
"error",
"--robot",
"--dry-run",
]);
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 dry-run JSON");
assert_eq!(json["dry_run"].as_bool(), Some(true));
assert_eq!(json["valid"].as_bool(), Some(true));
assert_eq!(json["query"].as_str(), Some("authentication error"));
assert_eq!(
json["explanation"]["filters_summary"]["agent_count"].as_u64(),
Some(1)
);
}
#[test]
fn robot_docs_defaults_to_guide_and_ignores_redundant_robot_flag() {
let mut cmd = base_cmd();
cmd.args(["robot-docs", "--robot", "--color=never"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("guide:"));
assert!(stdout.contains("Search contract:"));
assert!(
!stdout.contains('\u{1b}'),
"robot-docs should honor hoisted --color=never"
);
}
// =============================================================================
// P7.9: Robot-docs Provenance Output Tests
// Tests for provenance fields in robot/JSON output
// =============================================================================
/// Search results should include provenance fields (source_id) in default output
#[test]
fn search_json_includes_source_id_provenance() {
// P7.9: Search results should include source_id field
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if !hits.is_empty() {
let hit = &hits[0];
// source_id should be present and a string
assert!(
hit["source_id"].is_string(),
"Hit should have source_id provenance field"
);
// Default fixture data should be 'local'
assert_eq!(
hit["source_id"], "local",
"Fixture data should be from local source"
);
}
}
/// Search results with provenance preset should include origin fields
#[test]
fn search_fields_provenance_preset_expands() {
// P7.9: 'provenance' preset should expand to source_id,origin_kind,origin_host
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--fields",
"provenance,source_path",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if !hits.is_empty() {
let hit = &hits[0];
// Provenance preset fields should be present
assert!(
hit["source_id"].is_string(),
"Should have source_id from provenance preset"
);
// origin_kind may be null for local sources (that's okay)
assert!(
hit.get("origin_kind").is_some(),
"Should have origin_kind field in output"
);
// source_path should also be included
assert!(
hit["source_path"].is_string(),
"Should have source_path field"
);
}
}
/// Search results with default fields should include provenance in output
#[test]
fn search_default_output_includes_provenance_fields() {
// P7.9: Default search output (full fields) should include provenance
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let hits = json["hits"].as_array().expect("hits array");
if !hits.is_empty() {
let hit = &hits[0];
// Default output should include core provenance fields
assert!(
hit.get("source_id").is_some(),
"Default output should include source_id"
);
// origin_kind should be present (value may be "local" or other kind)
assert!(
hit.get("origin_kind").is_some(),
"Default output should include origin_kind"
);
// Note: origin_host is only included when using provenance preset,
// not in default output, so we don't check for it here
}
}
/// Introspect should show provenance in field presets or known fields
#[test]
fn introspect_lists_provenance_in_search_fields() {
// P7.9: Introspect should show provenance-related options for search
let mut cmd = base_cmd();
cmd.args(["introspect", "--json"]);
let assert = cmd.assert().success();
let output = assert.get_output();
let stdout = String::from_utf8_lossy(&output.stdout);
let json: Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
let commands = json["commands"].as_array().expect("commands array");
let search_cmd = commands
.iter()
.find(|c| c["name"] == "search")
.expect("search command should exist");
// Check for fields arg which should support provenance preset
let fields_arg = search_cmd["arguments"]
.as_array()
.and_then(|args| args.iter().find(|a| a["name"] == "fields"));
assert!(
fields_arg.is_some(),
"Search should have fields argument for filtering"
);
}
// =============================================================================
// ege.10: Additional Robot-Docs Topic Tests
// =============================================================================
/// robot-docs paths topic lists data directories
#[test]
fn robot_docs_paths_lists_directories() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "paths"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs paths should not emit ANSI when color=never"
);
// Should contain path-related content
assert!(
stdout.contains("data") || stdout.contains("path") || stdout.contains("directory"),
"paths topic should describe data directories"
);
}
/// robot-docs guide topic provides comprehensive usage guide
#[test]
fn robot_docs_guide_provides_usage_info() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "guide"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs guide should not emit ANSI when color=never"
);
// Should contain guide content
assert!(
stdout.contains("search") || stdout.contains("cass") || stdout.contains("agent"),
"guide topic should provide usage information"
);
assert!(
stdout.contains("cass robot-docs sources"),
"guide topic should point agents at the sources topic for source management flows"
);
}
/// robot-docs exit-codes topic lists all exit codes
#[test]
fn robot_docs_exit_codes_lists_codes() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "exit-codes"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs exit-codes should not emit ANSI when color=never"
);
// Should list exit codes
assert!(
stdout.contains('0') && stdout.contains('2') && stdout.contains('3'),
"exit-codes topic should list standard exit codes (0, 2, 3)"
);
}
/// robot-docs examples topic provides practical examples
#[test]
fn robot_docs_examples_provides_practical_examples() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "examples"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs examples should not emit ANSI when color=never"
);
// Should contain example commands
assert!(
stdout.contains("cass") && stdout.contains("--"),
"examples topic should show cass command examples"
);
assert!(
stdout.contains("cass sources agents exclude openclaw"),
"examples topic should document persistent harness exclusion"
);
}
/// robot-docs sources topic documents remote sources and persistent agent exclusions
#[test]
fn robot_docs_sources_documents_agent_exclusions() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "sources"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs sources should not emit ANSI when color=never"
);
assert!(
stdout.contains("cass sources agents exclude openclaw"),
"sources topic should document excluding a noisy harness"
);
assert!(
stdout.contains("--keep-indexed-data"),
"sources topic should document keeping already indexed data"
);
assert!(
stdout.contains("watch mode"),
"sources topic should explain that exclusions apply to future watch/index flows"
);
}
/// robot-docs contracts topic describes the API contract
#[test]
fn robot_docs_contracts_describes_api() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "contracts"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs contracts should not emit ANSI when color=never"
);
// Should describe contract/API info
assert!(
stdout.contains("contract") || stdout.contains("version") || stdout.contains("API"),
"contracts topic should describe API contract"
);
}
/// robot-docs analytics topic should describe the implemented safe repair path
#[test]
fn robot_docs_analytics_describes_safe_fix_mode() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "analytics"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs analytics should not emit ANSI when color=never"
);
assert!(
stdout.contains("cass analytics validate --fix --json"),
"analytics topic should document the implemented safe fix path"
);
assert!(
!stdout.contains("not yet implemented"),
"analytics topic should not claim implemented fix behavior is unavailable"
);
}
/// robot-docs wrap topic explains text wrapping
#[test]
fn robot_docs_wrap_explains_wrapping() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "robot-docs", "wrap"]);
let out = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains('\u{1b}'),
"robot-docs wrap should not emit ANSI when color=never"
);
// Should explain wrapping
assert!(
stdout.contains("wrap") || stdout.contains("width") || stdout.contains("column"),
"wrap topic should explain text wrapping options"
);
}
// =============================================================================
// ege.10: Golden Contract Tests
// =============================================================================
/// Introspect output should match golden contract (structure, not dynamic values)
#[test]
fn introspect_matches_golden_contract_structure() {
run_on_large_stack(|| {
let mut cmd = base_cmd();
cmd.args(["introspect", "--json"]);
let output = cmd.assert().success().get_output().clone();
assert!(
output.stderr.is_empty(),
"introspect should not log to stderr"
);
let actual: Value = serde_json::from_slice(&output.stdout).expect("valid introspect json");
// Check stable contract fields
assert_eq!(actual["api_version"], 1, "api_version should remain v1");
assert_eq!(
actual["contract_version"], "1",
"contract_version should remain v1"
);
// Check that global_flags exposes the shared automation-critical flags.
let actual_globals = actual["global_flags"]
.as_array()
.expect("global_flags array");
let actual_flag_names: HashSet<_> = actual_globals
.iter()
.filter_map(|f| f["name"].as_str())
.collect();
for name in [
"db",
"robot-help",
"trace-file",
"quiet",
"verbose",
"color",
] {
assert!(
actual_flag_names.contains(name),
"Expected global flag '{}' not found",
name
);
}
// Check that introspect stays aligned with clap's top-level command set.
let actual_cmds = actual["commands"].as_array().expect("commands array");
let actual_cmd_names: HashSet<_> = actual_cmds
.iter()
.filter_map(|c| c["name"].as_str())
.collect();
let clap_cmd = Cli::command();
let expected_cmd_names: HashSet<_> = clap_cmd
.get_subcommands()
.map(|command| command.get_name().to_string())
.collect();
assert_eq!(
actual_cmd_names,
expected_cmd_names
.iter()
.map(String::as_str)
.collect::<HashSet<_>>(),
"command names should match clap"
);
});
}
// =============================================================================
// ege.10: Comprehensive Exit Code Contract Tests
// =============================================================================
/// Exit code 0: Success for valid search
#[test]
fn exit_code_0_success_search() {
let mut cmd = base_cmd();
cmd.args([
"search",
"hello",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
cmd.assert().code(0);
}
/// Exit code 0: Success for valid stats
#[test]
fn exit_code_0_success_stats() -> Result<(), Box<dyn Error>> {
let fixture = isolated_search_demo_data()?;
let data_dir = fixture.path().to_str().unwrap();
let mut cmd = base_cmd();
cmd.args(["stats", "--json", "--data-dir", data_dir]);
cmd.assert().code(0);
Ok(())
}
/// Exit code 0: Success for robot-docs
#[test]
fn exit_code_0_success_robot_docs() {
let mut cmd = base_cmd();
cmd.args(["robot-docs", "commands"]);
cmd.assert().code(0);
}
/// Exit code 0: Success for capabilities
#[test]
fn exit_code_0_success_capabilities() {
let mut cmd = base_cmd();
cmd.args(["capabilities", "--json"]);
cmd.assert().code(0);
}
/// Exit code 2: Usage/parsing error for invalid subcommand
#[test]
fn exit_code_2_invalid_subcommand() {
let mut cmd = base_cmd();
cmd.args(["--json", "nonexistent_command"]);
cmd.assert().code(2);
}
/// Exit code 2: TUI disabled in non-TTY environment
#[test]
fn exit_code_2_tui_disabled_non_tty() {
let mut cmd = base_cmd();
// No subcommand triggers TUI which should be disabled in test
cmd.assert().code(2);
}
/// Exit code 3: Missing index for search
#[test]
fn exit_code_3_missing_index_search() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"search",
"test",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
// Missing index should return code 3 or 9 depending on how error is classified
let output = cmd.assert().failure().get_output().clone();
let code = output.status.code().expect("exit code");
assert!(
code == 3 || code == 9,
"Missing index should return code 3 or 9, got {code}"
);
}
/// Exit code 3: Missing database for stats
#[test]
fn exit_code_3_missing_db_stats() {
let tmp = TempDir::new().unwrap();
let mut cmd = base_cmd();
cmd.args([
"stats",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
cmd.assert().code(3);
}
/// Contract: All exit codes are documented in robot-docs exit-codes
#[test]
fn all_exit_codes_documented() {
let mut cmd = base_cmd();
cmd.args(["robot-docs", "exit-codes"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// All documented exit codes should be mentioned
for code in ["0", "2", "3", "9"] {
assert!(
stdout.contains(code),
"Exit code {} should be documented in robot-docs exit-codes",
code
);
}
}
// =============================================================================
// ege.10: Trace Mode Contract Tests
// =============================================================================
/// Trace file includes required contract fields on success
#[test]
fn trace_includes_contract_fields_on_success() {
let tmp = TempDir::new().unwrap();
let trace_path = tmp.path().join("trace.jsonl");
let mut cmd = base_cmd();
cmd.args([
"--trace-file",
trace_path.to_str().unwrap(),
"search",
"hello",
"--json",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
cmd.assert().success();
let trace = fs::read_to_string(&trace_path).expect("trace file exists");
let last_line = trace.lines().last().expect("trace has lines");
let json: Value = serde_json::from_str(last_line).expect("valid trace JSON");
// Required contract fields
assert_eq!(json["exit_code"], 0, "exit_code should be 0 for success");
assert_eq!(
json["contract_version"], "1",
"contract_version should be 1"
);
// Trace uses start_ts and end_ts for timestamps
assert!(
json["start_ts"].is_string() || json["end_ts"].is_string(),
"timestamp (start_ts/end_ts) should be present"
);
assert!(
json["duration_ms"].is_number(),
"duration_ms should be present"
);
}
/// Trace file includes error details on failure
#[test]
fn trace_includes_error_on_failure() {
let tmp = TempDir::new().unwrap();
let trace_path = tmp.path().join("trace.jsonl");
let mut cmd = base_cmd();
cmd.args([
"--trace-file",
trace_path.to_str().unwrap(),
"search",
"test",
"--json",
"--data-dir",
tmp.path().to_str().unwrap(),
]);
cmd.assert().failure();
let trace = fs::read_to_string(&trace_path).expect("trace file exists");
let last_line = trace.lines().last().expect("trace has lines");
let json: Value = serde_json::from_str(last_line).expect("valid trace JSON");
// Error case should have non-zero exit code
let exit_code = json["exit_code"].as_i64().expect("exit_code");
assert_ne!(exit_code, 0, "exit_code should be non-zero for failure");
assert_eq!(json["contract_version"], "1");
}
// =============================================================================
// TST.8: Global Flags & Defaults Coverage Tests
// Tests verifying global flags propagate and introspect shows defaults
// =============================================================================
/// Introspect should include quiet and verbose global flags with proper types
#[test]
fn introspect_global_flags_quiet_verbose_documented() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags array");
let mut found_quiet = false;
let mut found_verbose = false;
for flag in globals {
let name = flag["name"].as_str().unwrap_or_default();
match name {
"quiet" => {
found_quiet = true;
assert_eq!(flag["arg_type"], "flag", "quiet should be a flag type");
assert_eq!(flag["short"], "q", "quiet should have -q as short option");
}
"verbose" => {
found_verbose = true;
assert_eq!(flag["arg_type"], "flag", "verbose should be a flag type");
assert_eq!(flag["short"], "v", "verbose should have -v as short option");
}
_ => {}
}
}
assert!(found_quiet, "quiet should be documented in global_flags");
assert!(
found_verbose,
"verbose should be documented in global_flags"
);
}
/// Introspect should include robot-help global flag
#[test]
fn introspect_global_flags_robot_help_documented() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags array");
let found = globals.iter().any(|f| f["name"] == "robot-help");
assert!(found, "robot-help should be documented in global_flags");
}
/// Context argument should be documented in expand command with proper defaults
#[test]
fn introspect_expand_context_argument() {
let json = fetch_introspect_json();
let expand = find_command(&json, "expand");
let context = find_arg(expand, "context");
assert_eq!(
context["value_type"], "integer",
"context should be integer type"
);
// Expand context has default value of 3
assert_eq!(
context["default"], "3",
"expand --context should default to 3"
);
}
/// Context argument should be documented in view command with proper defaults
#[test]
fn introspect_view_context_argument() {
let json = fetch_introspect_json();
let view = find_command(&json, "view");
let context = find_arg(view, "context");
assert_eq!(
context["value_type"], "integer",
"context should be integer type"
);
// View context also has default of 5
assert_eq!(context["default"], "5", "context should default to 5");
}
/// All global flags mentioned in introspect should have required=false
#[test]
fn introspect_global_flags_all_optional() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags array");
for flag in globals {
let name = flag["name"].as_str().unwrap_or("unknown");
assert_eq!(
flag["required"], false,
"global flag {name} should not be required"
);
}
}
/// Verify complete list of expected global flags exists
#[test]
fn introspect_global_flags_complete_list() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags array");
let expected_flags = [
"db",
"robot-help",
"trace-file",
"quiet",
"verbose",
"color",
"progress",
"wrap",
"nowrap",
];
let actual_names: HashSet<_> = globals.iter().filter_map(|f| f["name"].as_str()).collect();
for expected in expected_flags {
assert!(
actual_names.contains(expected),
"global flag '{expected}' should be documented in introspect"
);
}
}
/// Status command should have stale-threshold with proper default
#[test]
fn introspect_status_stale_threshold_default() {
let json = fetch_introspect_json();
let status = find_command(&json, "status");
let stale = find_arg(status, "stale-threshold");
assert_eq!(
stale["value_type"], "integer",
"stale-threshold should be integer type"
);
assert_eq!(
stale["default"], "1800",
"stale-threshold should default to 1800 (30 minutes)"
);
}
/// Health command should have stale-threshold with proper default
#[test]
fn introspect_health_stale_threshold_default() {
let json = fetch_introspect_json();
let health = find_command(&json, "health");
let stale = find_arg(health, "stale-threshold");
assert_eq!(
stale["value_type"], "integer",
"stale-threshold should be integer type"
);
// Health uses a shorter default (5 minutes) for quick checks
assert_eq!(
stale["default"], "300",
"health --stale-threshold should default to 300 (5 minutes)"
);
}
/// Global --quiet flag should suppress info-level logs
#[test]
fn global_quiet_flag_suppresses_info_logs() {
let mut cmd = base_cmd();
cmd.args(["--quiet", "capabilities", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stderr = String::from_utf8_lossy(&output.stderr);
// With --quiet, stderr should not have INFO-level messages
assert!(
!stderr.contains("INFO"),
"INFO logs should be suppressed with --quiet"
);
}
/// Global --verbose flag should be accepted without error
#[test]
fn global_verbose_flag_accepted() {
let mut cmd = base_cmd();
cmd.args(["--verbose", "capabilities", "--json"]);
cmd.assert().success();
}
/// Global flags can be placed before or after subcommand
#[test]
fn global_flags_work_before_subcommand() {
let mut cmd = base_cmd();
cmd.args(["--color=never", "capabilities", "--json"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should not contain ANSI escape codes
assert!(
!stdout.contains('\u{1b}'),
"--color=never should disable ANSI codes"
);
}
/// Global --nowrap flag should be documented and accepted
#[test]
fn global_nowrap_flag_works() {
let mut cmd = base_cmd();
cmd.args(["--nowrap", "capabilities", "--json"]);
cmd.assert().success();
}
/// Global --wrap flag should accept integer value
#[test]
fn global_wrap_flag_accepts_integer() {
let mut cmd = base_cmd();
cmd.args(["--wrap", "80", "capabilities", "--json"]);
cmd.assert().success();
}
/// Search limit flag should have correct default in introspect
#[test]
fn introspect_search_limit_default() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let limit = find_arg(search, "limit");
assert_eq!(limit["value_type"], "integer");
assert_eq!(limit["default"], "0", "search --limit should default to 0");
}
/// Search offset flag should have correct default in introspect
#[test]
fn introspect_search_offset_default() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let offset = find_arg(search, "offset");
assert_eq!(offset["value_type"], "integer");
assert_eq!(
offset["default"], "0",
"search --offset should default to 0"
);
}
/// Progress flag should have enum values and default
#[test]
fn introspect_global_progress_enum_values() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags");
let progress = globals
.iter()
.find(|f| f["name"] == "progress")
.expect("progress flag exists");
assert_eq!(progress["value_type"], "enum");
assert_eq!(progress["default"], "auto");
let enum_values: HashSet<_> = progress["enum_values"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(enum_values.contains("auto"));
assert!(enum_values.contains("bars"));
assert!(enum_values.contains("plain"));
assert!(enum_values.contains("none"));
}
/// Color flag should have enum values and default
#[test]
fn introspect_global_color_enum_values() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags");
let color = globals
.iter()
.find(|f| f["name"] == "color")
.expect("color flag exists");
assert_eq!(color["value_type"], "enum");
assert_eq!(color["default"], "auto");
let enum_values: HashSet<_> = color["enum_values"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(enum_values.contains("auto"));
assert!(enum_values.contains("never"));
assert!(enum_values.contains("always"));
}
/// Dynamic schema builder should not introduce regressions - all commands present
#[test]
fn introspect_dynamic_schema_all_commands_present() {
let json = fetch_introspect_json();
let commands = json["commands"].as_array().expect("commands array");
let expected_commands = [
"tui",
"index",
"completions",
"search",
"status",
"diag",
"capabilities",
"introspect",
"robot-docs",
"api-version",
"view",
"expand",
"timeline",
"export",
"health",
"state",
"sources",
];
let actual_names: HashSet<_> = commands.iter().filter_map(|c| c["name"].as_str()).collect();
for expected in expected_commands {
assert!(
actual_names.contains(expected),
"command '{expected}' should be present in introspect schema"
);
}
}
/// Dynamic schema builder should include response_schemas section
#[test]
fn introspect_has_response_schemas() {
let json = fetch_introspect_json();
let schemas = json["response_schemas"].as_object();
assert!(
schemas.is_some(),
"introspect should include response_schemas"
);
assert!(
!schemas.unwrap().is_empty(),
"response_schemas should not be empty"
);
}
#[test]
fn introspect_response_schemas_advertise_doctor_v2_surfaces() {
let json = fetch_introspect_json();
let schemas = json["response_schemas"]
.as_object()
.expect("response_schemas object");
for key in [
"doctor-check",
"doctor-repair-dry-run",
"doctor-repair-receipt",
"doctor-archive-scan",
"doctor-archive-normalize",
"doctor-backups-list",
"doctor-backups-verify",
"doctor-baseline-diff",
"doctor-support-bundle",
"doctor-safe-auto-run",
"doctor-status-summary",
"doctor-health-summary",
] {
let schema = schemas
.get(key)
.unwrap_or_else(|| panic!("introspect response_schemas missing {key}"));
let properties = schema["properties"]
.as_object()
.unwrap_or_else(|| panic!("{key} schema should expose object properties"));
for field in [
"status",
"outcome_kind",
"asset_class",
"risk_level",
"fallback_mode",
"recommended_action",
"operation_outcome",
] {
assert!(
properties.contains_key(field),
"{key} schema missing branchable doctor metadata field {field}"
);
}
}
}
// =============================================================================
// TST.9: Repeatable + Path/Integer Inference Tests
// Tests for introspect correctly documenting repeatable options and type hints
// =============================================================================
/// Search command days parameter should be integer type
#[test]
fn introspect_search_days_integer_type() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let days = find_arg(search, "days");
assert_eq!(
days["value_type"], "integer",
"search --days should be integer type"
);
assert_eq!(
days["arg_type"], "option",
"search --days should be an option"
);
}
/// View command line parameter should be integer type
#[test]
fn introspect_view_line_integer_type() {
let json = fetch_introspect_json();
let view = find_command(&json, "view");
let line = find_arg(view, "line");
assert_eq!(
line["value_type"], "integer",
"view -n/--line should be integer type"
);
assert_eq!(
line["short"], "n",
"view --line should have short option -n"
);
}
/// Expand command line parameter should be integer type
#[test]
fn introspect_expand_line_integer_type() {
let json = fetch_introspect_json();
let expand = find_command(&json, "expand");
let line = find_arg(expand, "line");
assert_eq!(
line["value_type"], "integer",
"expand -n/--line should be integer type"
);
assert_eq!(
line["short"], "n",
"expand --line should have short option -n"
);
}
/// Search command agent parameter should be repeatable
#[test]
fn introspect_search_agent_repeatable() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let agent = find_arg(search, "agent");
assert_eq!(
agent["repeatable"], true,
"search --agent should be repeatable"
);
}
/// Search command workspace parameter should be repeatable
#[test]
fn introspect_search_workspace_repeatable() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let workspace = find_arg(search, "workspace");
assert_eq!(
workspace["repeatable"], true,
"search --workspace should be repeatable"
);
}
/// Index command watch-once parameter should be repeatable path
#[test]
fn introspect_index_watch_once_repeatable_path() {
let json = fetch_introspect_json();
let index = find_command(&json, "index");
let watch_once = find_arg(index, "watch-once");
assert_eq!(
watch_once["repeatable"], true,
"index --watch-once should be repeatable"
);
assert_eq!(
watch_once["value_type"], "path",
"index --watch-once should be path type"
);
}
/// Index command semantic flag should be documented as a flag.
#[test]
fn introspect_index_semantic_flag() {
let json = fetch_introspect_json();
let index = find_command(&json, "index");
let semantic = find_arg(index, "semantic");
assert_eq!(
semantic["arg_type"], "flag",
"index --semantic should be a flag"
);
}
/// Index command embedder should default to fastembed.
#[test]
fn introspect_index_embedder_default() {
let json = fetch_introspect_json();
let index = find_command(&json, "index");
let embedder = find_arg(index, "embedder");
assert_eq!(
embedder["value_type"], "string",
"index --embedder should be string type"
);
assert_eq!(
embedder["default"], "fastembed",
"index --embedder should default to fastembed"
);
}
/// Index command parsing should accept semantic + embedder flags.
#[test]
fn parse_index_semantic_embedder_flags() {
run_on_large_stack(|| {
let cli = Cli::try_parse_from(["cass", "index", "--semantic", "--embedder", "fastembed"])
.unwrap();
let command = cli.command.as_ref();
assert!(
matches!(command, Some(Commands::Index { .. })),
"expected index command, got {:?}",
cli.command
);
if let Some(Commands::Index {
semantic, embedder, ..
}) = command
{
assert!(*semantic, "semantic flag should be set");
assert_eq!(embedder.as_str(), "fastembed");
}
});
}
/// Index command parsing should default embedder to fastembed.
#[test]
fn parse_index_embedder_default() {
run_on_large_stack(|| {
let cli = Cli::try_parse_from(["cass", "index", "--semantic"]).unwrap();
let command = cli.command.as_ref();
assert!(
matches!(command, Some(Commands::Index { .. })),
"expected index command, got {:?}",
cli.command
);
if let Some(Commands::Index {
semantic, embedder, ..
}) = command
{
assert!(*semantic, "semantic flag should be set");
assert_eq!(embedder.as_str(), "fastembed");
}
});
}
/// Search command aggregate parameter should be repeatable
#[test]
fn introspect_search_aggregate_repeatable() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let aggregate = find_arg(search, "aggregate");
assert_eq!(
aggregate["repeatable"], true,
"search --aggregate should be repeatable"
);
}
/// Global db parameter should be path type
#[test]
fn introspect_global_db_path_type() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags");
let db = globals
.iter()
.find(|f| f["name"] == "db")
.expect("db flag exists");
assert_eq!(db["value_type"], "path", "global --db should be path type");
}
/// Global trace-file parameter should be path type
#[test]
fn introspect_global_trace_file_path_type() {
let json = fetch_introspect_json();
let globals = json["global_flags"].as_array().expect("global_flags");
let trace_file = globals
.iter()
.find(|f| f["name"] == "trace-file")
.expect("trace-file flag exists");
assert_eq!(
trace_file["value_type"], "path",
"global --trace-file should be path type"
);
}
/// View command path positional should be path type
#[test]
fn introspect_view_path_positional_type() {
let json = fetch_introspect_json();
let view = find_command(&json, "view");
let path = find_arg(view, "path");
assert_eq!(
path["value_type"], "path",
"view path positional should be path type"
);
assert_eq!(
path["arg_type"], "positional",
"view path should be positional argument"
);
}
/// Expand command path positional should be path type
#[test]
fn introspect_expand_path_positional_type() {
let json = fetch_introspect_json();
let expand = find_command(&json, "expand");
let path = find_arg(expand, "path");
assert_eq!(
path["value_type"], "path",
"expand path positional should be path type"
);
assert_eq!(
path["arg_type"], "positional",
"expand path should be positional argument"
);
}
/// Search command data-dir parameter should be path type
#[test]
fn introspect_search_data_dir_path_type() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
let data_dir = find_arg(search, "data-dir");
assert_eq!(
data_dir["value_type"], "path",
"search --data-dir should be path type"
);
}
/// Context command limit parameter should be integer type
#[test]
fn introspect_context_limit_integer_type() {
let json = fetch_introspect_json();
let context = find_command(&json, "context");
let limit = find_arg(context, "limit");
assert_eq!(
limit["value_type"], "integer",
"context --limit should be integer type"
);
}
/// All repeatable options documented correctly across commands
#[test]
fn introspect_all_repeatable_options_documented() {
let json = fetch_introspect_json();
// Check search command repeatables
let search = find_command(&json, "search");
for name in ["agent", "workspace", "aggregate"] {
let arg = find_arg(search, name);
assert_eq!(
arg["repeatable"], true,
"search --{name} should be marked repeatable"
);
}
// Check index command repeatables
let index = find_command(&json, "index");
let watch_once = find_arg(index, "watch-once");
assert_eq!(
watch_once["repeatable"], true,
"index --watch-once should be marked repeatable"
);
}
/// All path-type options documented correctly across commands
#[test]
fn introspect_all_path_options_documented() {
let json = fetch_introspect_json();
// Check global path types
let globals = json["global_flags"].as_array().expect("global_flags");
for name in ["db", "trace-file"] {
let msg = format!("{name} exists");
let flag = globals.iter().find(|f| f["name"] == name).expect(&msg);
assert_eq!(
flag["value_type"], "path",
"global --{name} should be path type"
);
}
// Check command path types
let search = find_command(&json, "search");
assert_eq!(
find_arg(search, "data-dir")["value_type"],
"path",
"search --data-dir should be path type"
);
let view = find_command(&json, "view");
assert_eq!(
find_arg(view, "path")["value_type"],
"path",
"view path should be path type"
);
}
/// All integer-type options documented correctly
#[test]
fn introspect_all_integer_options_documented() {
let json = fetch_introspect_json();
let search = find_command(&json, "search");
for name in ["limit", "offset", "days"] {
let arg = find_arg(search, name);
assert_eq!(
arg["value_type"], "integer",
"search --{name} should be integer type"
);
}
let view = find_command(&json, "view");
for name in ["line", "context"] {
let arg = find_arg(view, name);
assert_eq!(
arg["value_type"], "integer",
"view --{name} should be integer type"
);
}
let expand = find_command(&json, "expand");
for name in ["line", "context"] {
let arg = find_arg(expand, name);
assert_eq!(
arg["value_type"], "integer",
"expand --{name} should be integer type"
);
}
let status = find_command(&json, "status");
assert_eq!(
find_arg(status, "stale-threshold")["value_type"],
"integer",
"status --stale-threshold should be integer type"
);
let health = find_command(&json, "health");
assert_eq!(
find_arg(health, "stale-threshold")["value_type"],
"integer",
"health --stale-threshold should be integer type"
);
}
// ============================================================================
// TOON FORMAT INTEGRATION TESTS
// ============================================================================
/// Test that --robot-format toon is accepted as valid option
#[test]
fn robot_format_toon_is_valid_option() {
let mut cmd = base_cmd();
// Should not fail with "invalid value" error
// Use --limit 1 since limit 0 causes panic in tantivy
cmd.args([
"search",
"hello",
"--robot-format",
"toon",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
// Ensure the flag is accepted and command succeeds.
cmd.assert().success();
}
/// Test that CASS_OUTPUT_FORMAT=toon env var is respected
#[test]
fn cass_output_format_env_triggers_robot_mode() {
let mut cmd = base_cmd();
cmd.env("CASS_OUTPUT_FORMAT", "json");
cmd.args([
"search",
"hello",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output JSON since CASS_OUTPUT_FORMAT=json sets robot mode
assert!(
stdout.trim().starts_with('{') || stdout.trim().starts_with('['),
"CASS_OUTPUT_FORMAT=json should produce JSON output"
);
}
/// Test that TOON_DEFAULT_FORMAT=json env var works
#[test]
fn toon_default_format_env_json_works() {
let mut cmd = base_cmd();
cmd.env("TOON_DEFAULT_FORMAT", "json");
cmd.args([
"search",
"hello",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Should output JSON
assert!(
stdout.trim().starts_with('{') || stdout.trim().starts_with('['),
"TOON_DEFAULT_FORMAT=json should produce JSON output"
);
}
/// Test that CLI flag overrides env vars
#[test]
fn cli_robot_format_overrides_env() {
let mut cmd = base_cmd();
cmd.env("CASS_OUTPUT_FORMAT", "compact");
cmd.args([
"search",
"hello",
"--robot-format",
"json",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Pretty JSON has newlines, compact doesn't (if env var was respected wrongly)
// This test is checking that --robot-format json overrides compact
let json: Value = serde_json::from_str(stdout.trim()).expect("valid json");
assert!(json.is_object(), "output should be valid JSON object");
}
/// Test that --robot-format toon help shows toon in possible values
#[test]
fn robot_format_help_includes_toon() {
let mut cmd = base_cmd();
cmd.args(["search", "--help"]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.to_lowercase().contains("toon"),
"search --help should mention toon format option"
);
}
/// Test that introspect shows toon in robot-format enum values
#[test]
fn introspect_robot_format_includes_toon() {
let mut cmd = base_cmd();
cmd.args(["introspect", "--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 introspect json");
let commands = json["commands"].as_array().expect("commands array");
let search = commands
.iter()
.find(|c| c["name"] == "search")
.expect("search command present");
let args = search["arguments"].as_array().expect("search args");
let robot_format = args
.iter()
.find(|a| a["name"] == "robot-format")
.expect("robot-format arg should exist");
let enum_values = robot_format["enum_values"]
.as_array()
.expect("robot-format should have enum_values");
assert!(
enum_values.iter().any(|v| v == "toon"),
"robot-format enum_values should include toon"
);
}
/// Test that CASS_OUTPUT_FORMAT takes precedence over TOON_DEFAULT_FORMAT
#[test]
fn cass_output_format_takes_precedence() {
let mut cmd = base_cmd();
// Set both env vars - CASS_OUTPUT_FORMAT should win
cmd.env("TOON_DEFAULT_FORMAT", "compact");
cmd.env("CASS_OUTPUT_FORMAT", "json");
cmd.args([
"search",
"hello",
"--limit",
"1",
"--data-dir",
"tests/fixtures/search_demo_data",
]);
let output = cmd.assert().success().get_output().clone();
let stdout = String::from_utf8_lossy(&output.stdout);
// Pretty JSON has newlines
assert!(
stdout.contains('\n'),
"CASS_OUTPUT_FORMAT=json should produce pretty JSON (with newlines), not compact"
);
}
// ========================================================================
// Bead coding_agent_session_search-s0cmk (child of ibuuh.10):
//
// Pins the truthful-fallback surface of `cass search` when the
// canonical DB is intact but the versioned lexical (Tantivy) tree has
// been wiped — an upgrade-accident, manual rm, or partial-copy
// condition. The existing `search_missing_index_*` tests only cover
// the "nothing exists" case (empty --data-dir), so this code path was
// silently un-pinned.
//
// The actual user-visible contract, per `SearchClient::open_with_options`
// and the `!client.has_tantivy()` warning branch in src/lib.rs (around
// line 7798), is:
//
// - `cass search` returns exit code 0 (degraded, not a hard failure).
// - stderr carries a human-readable warning that names the exact
// lexical path and the recovery command (`cass index --full`).
// - stdout is still a valid JSON envelope so agents can parse it.
//
// This is the "fail open even when lexical itself is missing" slice of
// ibuuh.10's core contract — cass preserves partial functionality AND
// surfaces a truthful recovery hint instead of panicking or lying. If a
// future refactor flips this to a hard failure, drops the warning, or
// stops naming `cass index --full` in the recovery text, this test
// fires immediately.
// ========================================================================
fn seed_codex_session_s0cmk(codex_home: &std::path::Path, filename: &str, keyword: &str) {
// User-only corpus (no assistant line) — the s0cmk scenario only
// needs the keyword to be present once from the user side.
util::seed_codex_session(codex_home, filename, keyword, false);
}
fn isolated_cass_cmd(temp_home: &std::path::Path) -> Command {
let mut cmd = Command::new(cass_bin());
cmd.current_dir(temp_home);
cmd.env("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT", "1");
cmd.env("CASS_IGNORE_SOURCES_CONFIG", "1");
cmd.env("HOME", temp_home);
cmd.env("XDG_DATA_HOME", temp_home.join(".local/share"));
cmd.env("XDG_CONFIG_HOME", temp_home.join(".config"));
cmd.env("CODEX_HOME", temp_home.join(".codex"));
cmd
}
#[test]
fn search_with_intact_db_but_wiped_lexical_degrades_with_truthful_warning() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.join(".codex");
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Filename MUST start with `rollout-` so franken_agent_detection's
// Codex connector actually ingests the fixture (see
// franken_agent_detection/src/connectors/codex.rs::is_rollout_file).
// Without the prefix the connector silently skips the file and
// `cass index --full` produces an empty DB — the test would still
// pass on its warning-text contract but the seeded keyword would
// never be visible to search, defeating future content-dependent
// assertions in this area.
seed_codex_session_s0cmk(&codex_home, "rollout-s0cmk-01.jsonl", "dbexistsprobe");
// Full index to produce BOTH the canonical DB and the lexical tree.
let mut idx = isolated_cass_cmd(home);
idx.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir);
let idx_out = idx.output().expect("run cass index --full");
assert!(
idx_out.status.success(),
"initial index must succeed. stdout: {} stderr: {}",
String::from_utf8_lossy(&idx_out.stdout),
String::from_utf8_lossy(&idx_out.stderr),
);
let db_path = data_dir.join("agent_search.db");
assert!(
db_path.exists(),
"precondition: canonical DB must exist after index --full"
);
// Wipe ONLY the versioned lexical index directory; keep the DB.
let index_path = coding_agent_search::search::tantivy::index_dir(&data_dir)
.expect("resolve versioned tantivy index path");
assert!(
index_path.exists(),
"precondition: lexical index must exist before wipe; got {}",
index_path.display()
);
fs::remove_dir_all(&index_path).expect("wipe lexical index directory");
assert!(
db_path.exists(),
"postcondition: wipe must leave canonical DB intact"
);
assert!(
!index_path.exists(),
"postcondition: wipe must remove the versioned lexical index directory"
);
// Run cass search against the half-torn state.
let mut search = isolated_cass_cmd(home);
search
.args(["search", "dbexistsprobe", "--json", "--data-dir"])
.arg(&data_dir);
let output = search.output().expect("run cass search");
let exit_code = output.status.code().expect("exit code present");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// CONTRACT PIN 1: exit 0 — degraded, not a hard failure. If this
// flips to nonzero, either the contract intentionally changed (and
// this test needs to be updated alongside the src change) or a
// regression just broke partial-functionality for all users whose
// lexical tree got wiped by an upgrade/backup-restore/rm accident.
assert_eq!(
exit_code, 0,
"cass search with DB+missing-lexical must serve degraded results (exit 0), \
not hard-fail. stdout: {stdout}\nstderr: {stderr}"
);
// CONTRACT PIN 2: the missing derived lexical tree is repaired
// automatically from the canonical DB. The old contract printed a
// manual `cass index --full` warning here; the self-healing search
// contract should make that repair invisible to robot consumers.
assert!(
index_path.exists(),
"search must recreate the missing lexical index from the canonical DB"
);
assert!(
stderr.trim().is_empty(),
"automatic lexical repair should not require a manual-rebuild warning; got: {stderr}"
);
// CONTRACT PIN 3: stdout is still valid JSON so agents parse it.
// We don't assert a specific hit count (degraded mode may return
// 0, depending on internal DB fallback behavior) — only that the
// CLI does not panic or emit garbage to stdout.
assert!(
!stdout.trim().is_empty(),
"stdout must not be empty in degraded mode; warning belongs on stderr. \
stderr: {stderr}"
);
let _: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|err| {
panic!(
"stdout must be valid JSON in degraded mode; got parse error {err}; \
stdout: {stdout}"
)
});
}
// ========================================================================
// Bead coding_agent_session_search-ibuuh.10 (sub-slice):
//
// Pins the truthful-fallback contract for `cass search --mode semantic`
// when the embedder / semantic assets are absent. The default cass
// install does NOT download the ~90MB MiniLM model (per AGENTS.md
// "Search Asset Contract: Semantic model acquisition is opt-in"), so
// every fresh install lands in this state.
//
// Two acceptable contract shapes for explicit-semantic-mode under that
// state, codified in src/lib.rs::SearchMode::Semantic (around line 8111):
//
// - Fail HARD with kind="semantic-unavailable", code=15, retryable=false,
// and a hint that names `--mode lexical` as the recovery path.
//
// The sibling tests already pin the default-hybrid + explicit-hybrid
// fail-open path (e2e_lexical_fail_open.rs::
// explicit_hybrid_mode_fails_open_to_lexical_when_semantic_assets_missing
// and default_hybrid_hit_list_equals_explicit_lexical_when_semantic_absent),
// but explicit `--mode semantic` is intentionally STRICTER: when the
// user explicitly asks for semantic, cass refuses to silently downgrade
// because that would mask a misconfiguration. Pinning this contract
// means a future refactor that "helpfully" added silent fail-open on
// explicit-semantic-mode would trip immediately, signaling a quality
// regression to operators who actually wanted the semantic tier.
//
// Invariants pinned here:
// 1. Exit code is non-zero (the planner refused to fall back).
// 2. The error envelope on stderr contains kind="semantic-unavailable"
// and code=15 (matches src/lib.rs Exit Codes table for kind 15).
// 3. error.retryable=false (this is a missing-asset state, not a
// transient failure; retrying without installing the model is
// pointless and burns budget).
// 4. error.hint names `--mode lexical` so the operator knows the
// cheap-fix recovery path without having to grep docs.
// 5. error.message is non-empty (the contract pinned by 7k7pl for
// the missing-index family applies here too).
// ========================================================================
#[test]
fn search_explicit_semantic_mode_errors_when_embedder_absent() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.join(".codex");
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Seed a Codex session so the canonical DB and lexical index BOTH
// exist — we want to isolate "semantic assets are missing" from
// "everything is missing" (which would just trip the missing-index
// contract). Reuse the s0cmk fixture builder above.
seed_codex_session_s0cmk(
&codex_home,
"rollout-ibuuh10-explicit-semantic-01.jsonl",
"explicitsemanticprobe",
);
let mut idx = isolated_cass_cmd(home);
idx.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir);
let idx_out = idx.output().expect("run cass index --full");
assert!(
idx_out.status.success(),
"initial index must succeed before we can probe semantic-mode behavior. \
stdout: {} stderr: {}",
String::from_utf8_lossy(&idx_out.stdout),
String::from_utf8_lossy(&idx_out.stderr),
);
// Now request explicit-semantic search. With no embedder model
// installed (default state), this MUST fail with semantic-unavailable
// rather than silently falling back to lexical.
let mut search = isolated_cass_cmd(home);
search
.args([
"search",
"explicitsemanticprobe",
"--json",
"--mode",
"semantic",
"--limit",
"5",
"--data-dir",
])
.arg(&data_dir);
let out = search.output().expect("run cass search --mode semantic");
assert!(
!out.status.success(),
"cass search --mode semantic must NOT succeed when the embedder is absent — \
silent fallback would mask a misconfiguration. \
stdout: {} stderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
// The error envelope is the LAST non-empty line of stderr (matches
// the existing search_missing_index_returns_json_error_contract
// pattern which also has to skip stray warnings).
let stderr = String::from_utf8_lossy(&out.stderr);
let last_line = stderr
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or_else(|| panic!("stderr should contain a JSON error line; got: {stderr}"));
let payload: Value = serde_json::from_str(last_line.trim()).unwrap_or_else(|err| {
panic!("error envelope must be valid JSON: {err}; line: {last_line}")
});
let err = payload
.get("error")
.and_then(|e| e.as_object())
.unwrap_or_else(|| panic!("payload must contain an `error` object; got: {payload}"));
// Invariant 2: kebab-case kind + numeric code from src/lib.rs Exit
// Codes table. Pinning both the kind AND the code catches a
// regression in either direction (kind drift to "missing-embedder"
// or code drift to a different number).
assert_eq!(
err.get("kind").and_then(Value::as_str),
Some("semantic-unavailable"),
"explicit semantic mode without embedder must surface kind=semantic-unavailable; got: {err:?}"
);
assert_eq!(
err.get("code").and_then(Value::as_i64),
Some(15),
"explicit semantic mode without embedder must surface code=15 \
(per AGENTS.md Exit Codes table); got: {err:?}"
);
// Invariant 3: NOT retryable. A retry loop without installing the
// model would burn budget on the same error.
assert_eq!(
err.get("retryable").and_then(Value::as_bool),
Some(false),
"semantic-unavailable must be reported as non-retryable so agents don't loop; got: {err:?}"
);
// Invariant 4: hint names the semantic setup path and avoids the
// legacy lexical override hint. Lexical is already the default fail-open
// behavior when the operator does not explicitly request semantic-only
// search, so the recovery is to drop the semantic-only request or build
// the semantic assets.
let hint = err
.get("hint")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("error must include a `hint` operator can act on; got: {err:?}"));
let hint_lower = hint.to_lowercase();
let legacy_lexical_hint = concat!("--mode ", "lexical");
assert!(
!hint_lower.contains(legacy_lexical_hint),
"hint must not point operators at the legacy lexical override; got: {hint:?}"
);
assert!(
hint_lower.contains("index --semantic") && hint_lower.contains("omit --mode semantic"),
"hint must explain how to build semantic assets or drop semantic-only mode; got: {hint:?}"
);
// Invariant 5: non-empty message string (matches the 7k7pl contract
// already pinned on the missing-index family).
let message = err
.get("message")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("error must include a non-null message; got: {err:?}"));
assert!(
!message.is_empty(),
"error message must be a non-empty diagnostic string; got: {err:?}"
);
}
// ========================================================================
// Bead coding_agent_session_search-1dd5u (child of ibuuh.10):
// Metamorphic invariants for `cass search` query handling.
//
// Existing search tests pin specific-query regressions against a
// curated fixture. This trio pins the CROSS-QUERY invariants the
// documentation implies but no test currently enforces:
//
// 1. Case-insensitivity — search "FOO" must return the same hit set
// (same source_path+line_number keys in the same order) as
// search "foo". Tantivy's default tokenizer lowercases terms at
// index-and-query time; a refactor that swapped it for a
// case-sensitive tokenizer or removed the lowercase step would
// silently break this invariant and no current test notices.
//
// 2. Whitespace-trim — search " foo " must return the same hit
// set as search "foo". The query-normalization path trims
// leading/trailing whitespace; a refactor that trusted the
// operator to pre-trim would silently regress this surface.
//
// 3. Limit monotonicity — the top-N hits of `search X --limit N`
// must be a prefix of the top-M hits of `search X --limit M` for
// M > N. This is the deterministic-ordering property the pager
// and `--cursor` paths rely on; a regression that applied the
// limit BEFORE ranking would trip this test immediately.
//
// All three use the `seed_codex_session_s0cmk` + `isolated_cass_cmd`
// helpers already defined above so no new test fixture is introduced.
// ========================================================================
fn search_hits_as_keys(payload: &Value) -> Vec<(String, i64)> {
// Panic-on-null matches the jogco review fixup (bd-7qtn5 sibling
// work): silently defaulting null fields would let two malformed
// responses compare equal and mask a real regression.
payload
.get("hits")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.iter()
.map(|h| {
let path = h
.get("source_path")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("hit.source_path must be a non-null string; hit: {h}"))
.to_string();
let line = h
.get("line_number")
.and_then(Value::as_i64)
.unwrap_or_else(|| panic!("hit.line_number must be a non-null integer; hit: {h}"));
(path, line)
})
.collect()
}
fn run_search_returning_payload(
home: &std::path::Path,
data_dir: &std::path::Path,
args: &[&str],
) -> Value {
let mut cmd = isolated_cass_cmd(home);
cmd.args(args).arg(data_dir);
let out = cmd.output().expect("run cass search");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"cass search invocation must succeed (args={args:?}); stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_str(stdout.trim())
.unwrap_or_else(|err| panic!("search stdout must be valid JSON: {err}; stdout: {stdout}"))
}
/// Common setup: seed 3 Codex rollouts, run cass index --full, return
/// (tempdir, home path, data_dir path) so each metamorphic test can
/// drop straight into invoking cass search against the known corpus.
fn seed_metamorphic_corpus() -> (TempDir, std::path::PathBuf, std::path::PathBuf) {
let tmp = TempDir::new().unwrap();
let home = tmp.path().to_path_buf();
let codex_home = home.join(".codex");
let data_dir = home.join("cass_data");
std::fs::create_dir_all(&data_dir).unwrap();
for idx in 1..=3 {
let name = format!("rollout-meta-{idx:02}.jsonl");
seed_codex_session_s0cmk(&codex_home, &name, "metamorphprobe");
}
let mut index = isolated_cass_cmd(&home);
index
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir);
let out = index.output().expect("run cass index --full");
assert!(
out.status.success(),
"seed index --full must succeed; stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(tmp, home, data_dir)
}
#[test]
fn search_is_case_insensitive_for_ascii_queries() {
let (_tmp, home, data_dir) = seed_metamorphic_corpus();
let lower = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
"metamorphprobe",
"--json",
"--limit",
"20",
"--data-dir",
],
);
let upper = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
"METAMORPHPROBE",
"--json",
"--limit",
"20",
"--data-dir",
],
);
let mixed = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
"MetaMorphProbe",
"--json",
"--limit",
"20",
"--data-dir",
],
);
let lower_keys = search_hits_as_keys(&lower);
assert!(
!lower_keys.is_empty(),
"precondition: lower-case search must return hits for the seeded keyword; \
payload: {lower}"
);
assert_eq!(
lower_keys,
search_hits_as_keys(&upper),
"search must be case-insensitive: lower-case and upper-case queries \
must return identical hit keys in identical order"
);
assert_eq!(
lower_keys,
search_hits_as_keys(&mixed),
"search must be case-insensitive: lower-case and mixed-case queries \
must return identical hit keys in identical order"
);
// total_matches must also match — catches a regression where the
// lower/upper paths returned the same hits but reported a
// different count (would leak via pagination UIs).
assert_eq!(
lower.get("total_matches"),
upper.get("total_matches"),
"total_matches must agree across case variants; lower: {lower}\nupper: {upper}"
);
}
#[test]
fn search_trims_leading_and_trailing_whitespace_from_query() {
let (_tmp, home, data_dir) = seed_metamorphic_corpus();
let bare = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
"metamorphprobe",
"--json",
"--limit",
"20",
"--data-dir",
],
);
let padded = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
" metamorphprobe ",
"--json",
"--limit",
"20",
"--data-dir",
],
);
let tabs_and_newlines = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
"\tmetamorphprobe\n",
"--json",
"--limit",
"20",
"--data-dir",
],
);
let bare_keys = search_hits_as_keys(&bare);
assert!(
!bare_keys.is_empty(),
"precondition: unpadded search must return hits; payload: {bare}"
);
assert_eq!(
bare_keys,
search_hits_as_keys(&padded),
"search must trim leading/trailing spaces: bare and space-padded queries \
must return identical hit keys in identical order"
);
assert_eq!(
bare_keys,
search_hits_as_keys(&tabs_and_newlines),
"search must trim leading/trailing whitespace (tabs + newlines too): \
bare and whitespace-padded queries must return identical hit keys"
);
}
#[test]
fn search_limit_monotonicity_smaller_is_prefix_of_larger() {
let (_tmp, home, data_dir) = seed_metamorphic_corpus();
let small = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
"metamorphprobe",
"--json",
"--limit",
"2",
"--data-dir",
],
);
let large = run_search_returning_payload(
&home,
&data_dir,
&[
"search",
"metamorphprobe",
"--json",
"--limit",
"20",
"--data-dir",
],
);
let small_keys = search_hits_as_keys(&small);
let large_keys = search_hits_as_keys(&large);
assert!(
!small_keys.is_empty(),
"precondition: small-limit search must return at least one hit; \
small: {small}"
);
assert!(
large_keys.len() >= small_keys.len(),
"larger --limit must return at least as many hits as a smaller one; \
small.len()={}, large.len()={}",
small_keys.len(),
large_keys.len(),
);
// The prefix property: every key returned at --limit 2 must be
// present in the same position at --limit 20. This is what the
// pager's `--cursor`/`--offset` path relies on.
assert_eq!(
&large_keys[..small_keys.len()],
small_keys.as_slice(),
"--limit N hits must be a prefix of --limit M hits (M > N); \
small.keys={small_keys:?}\nlarge.keys (prefix)={:?}",
&large_keys[..small_keys.len().min(large_keys.len())],
);
// Also pin that total_matches is invariant under --limit — the
// limit only clamps how MANY we return, not the reported universe
// size.
assert_eq!(
small.get("total_matches"),
large.get("total_matches"),
"total_matches must be invariant across --limit; small: {small}\nlarge: {large}"
);
}
// ========================================================================
// Bead coding_agent_session_search-pdg22 (child of ibuuh.10):
// Metamorphic invariants for `cass stats --json`.
//
// `cass stats --json` aggregates counts (total conversations, total
// messages, per-agent breakdown, top workspaces, date range) over the
// entire canonical DB. The existing suite only asserts specific
// fixture snapshots; nothing pins the GENERIC invariants stats
// aggregation must preserve across any corpus:
//
// 1. By-agent sum == total conversations. If the sum drifts, some
// agent's contribution is lost or double-counted — a real bug
// the snapshot tests don't catch because they only inspect one
// frozen corpus.
//
// 2. date_range.oldest <= date_range.newest (when both are present).
// An aggregation regression that swapped min/max or produced
// timezone-inconsistent timestamps would trip here.
//
// 3. Empty DB (fresh index with no sessions) → conversations=0,
// messages=0, by_agent=[]. A regression that inherited cached
// values from a prior run or hallucinated counts would fire.
//
// All three seed a fresh corpus (or explicitly empty one) per test
// via the jogco helpers already defined above, so no cross-test state
// bleed.
// ========================================================================
#[test]
fn stats_by_agent_counts_sum_to_total_conversations() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.join(".codex");
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Seed 3 rollouts so the by_agent bucket has meaningful data.
for idx in 1..=3 {
let name = format!("rollout-stats-{idx:02}.jsonl");
seed_codex_session_s0cmk(&codex_home, &name, "statsprobe");
}
let mut idx_cmd = isolated_cass_cmd(home);
idx_cmd
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir);
let idx_out = idx_cmd.output().expect("run cass index --full");
assert!(
idx_out.status.success(),
"cass index --full must succeed on seeded corpus. stderr: {}",
String::from_utf8_lossy(&idx_out.stderr)
);
let mut stats_cmd = isolated_cass_cmd(home);
stats_cmd
.args(["stats", "--json", "--data-dir"])
.arg(&data_dir);
let stats_out = stats_cmd.output().expect("run cass stats --json");
assert!(
stats_out.status.success(),
"cass stats --json must succeed; stderr: {}",
String::from_utf8_lossy(&stats_out.stderr)
);
let stats: Value = serde_json::from_slice(&stats_out.stdout)
.unwrap_or_else(|err| panic!("stats JSON parse failed: {err}"));
let total = stats
.get("conversations")
.and_then(Value::as_u64)
.unwrap_or_else(|| panic!("stats.conversations must be a non-null u64; stats: {stats}"));
assert!(
total >= 1,
"precondition: seeded corpus must produce at least 1 conversation; \
stats: {stats}"
);
let by_agent = stats
.get("by_agent")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("stats.by_agent must be an array; stats: {stats}"));
let mut agent_sum: u64 = 0;
for entry in by_agent {
let agent = entry
.get("agent")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("by_agent entry must have non-null `agent`; entry: {entry}"));
assert!(
!agent.is_empty(),
"by_agent.agent must be non-empty; entry: {entry}"
);
let count = entry
.get("count")
.and_then(Value::as_u64)
.unwrap_or_else(|| {
panic!("by_agent entry must have non-null u64 `count`; entry: {entry}")
});
agent_sum = agent_sum
.checked_add(count)
.unwrap_or_else(|| panic!("by_agent count overflow; accumulated {agent_sum}"));
}
assert_eq!(
agent_sum, total,
"sum of by_agent[].count must equal conversations total; \
sum={agent_sum} total={total}\nstats: {stats}"
);
}
#[test]
fn stats_date_range_oldest_is_not_after_newest() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let codex_home = home.join(".codex");
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
for idx in 1..=3 {
let name = format!("rollout-daterange-{idx:02}.jsonl");
seed_codex_session_s0cmk(&codex_home, &name, "staterange");
}
let mut idx_cmd = isolated_cass_cmd(home);
idx_cmd
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir);
assert!(
idx_cmd.output().expect("run index").status.success(),
"seed index must succeed"
);
let mut stats_cmd = isolated_cass_cmd(home);
stats_cmd
.args(["stats", "--json", "--data-dir"])
.arg(&data_dir);
let stats_out = stats_cmd.output().expect("run cass stats");
let stats: Value = serde_json::from_slice(&stats_out.stdout)
.unwrap_or_else(|err| panic!("stats JSON parse failed: {err}"));
// date_range may be absent if no messages have timestamps — but
// with seeded rollouts it must be present AND ordered.
let date_range = stats
.get("date_range")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("stats.date_range must be an object; stats: {stats}"));
let oldest = date_range
.get("oldest")
.and_then(Value::as_str)
.unwrap_or_else(|| {
panic!("stats.date_range.oldest must be a string on a seeded corpus; stats: {stats}")
});
let newest = date_range
.get("newest")
.and_then(Value::as_str)
.unwrap_or_else(|| {
panic!("stats.date_range.newest must be a string on a seeded corpus; stats: {stats}")
});
// Lexicographic string compare is safe for RFC3339 timestamps
// (they're fixed-width and zero-padded), AND the actual parsed
// comparison gives extra robustness against a format regression.
assert!(
oldest <= newest,
"date_range.oldest must lex-sort <= newest; oldest={oldest:?} newest={newest:?}"
);
let oldest_dt = chrono::DateTime::parse_from_rfc3339(oldest)
.unwrap_or_else(|err| panic!("oldest must be RFC3339: {err}; value: {oldest:?}"));
let newest_dt = chrono::DateTime::parse_from_rfc3339(newest)
.unwrap_or_else(|err| panic!("newest must be RFC3339: {err}; value: {newest:?}"));
assert!(
oldest_dt <= newest_dt,
"date_range parsed ordering must hold: {oldest_dt} <= {newest_dt}"
);
}
#[test]
fn stats_on_empty_indexed_db_reports_zeroes_and_empty_by_agent() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let data_dir = home.join("cass_data");
fs::create_dir_all(&data_dir).unwrap();
// Deliberately no seeded rollouts — cass index --full against an
// empty CODEX_HOME must produce a DB with zero user content, and
// stats must reflect that truthfully.
let mut idx_cmd = isolated_cass_cmd(home);
idx_cmd
.args(["index", "--full", "--json", "--data-dir"])
.arg(&data_dir);
let idx_out = idx_cmd.output().expect("run cass index --full");
assert!(
idx_out.status.success(),
"cass index --full must succeed even on an empty source tree; stderr: {}",
String::from_utf8_lossy(&idx_out.stderr)
);
let mut stats_cmd = isolated_cass_cmd(home);
stats_cmd
.args(["stats", "--json", "--data-dir"])
.arg(&data_dir);
let stats_out = stats_cmd.output().expect("run cass stats");
assert!(
stats_out.status.success(),
"cass stats must succeed against an empty indexed DB (not error); \
stderr: {}",
String::from_utf8_lossy(&stats_out.stderr)
);
let stats: Value = serde_json::from_slice(&stats_out.stdout)
.unwrap_or_else(|err| panic!("stats JSON parse failed: {err}"));
assert_eq!(
stats.get("conversations").and_then(Value::as_u64),
Some(0),
"empty indexed DB must report conversations=0; stats: {stats}"
);
assert_eq!(
stats.get("messages").and_then(Value::as_u64),
Some(0),
"empty indexed DB must report messages=0; stats: {stats}"
);
let by_agent = stats
.get("by_agent")
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("stats.by_agent must be an array; stats: {stats}"));
assert!(
by_agent.is_empty(),
"empty indexed DB must produce empty by_agent[]; got {} entries: {by_agent:?}",
by_agent.len()
);
}