mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
e1ad357558
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>
8153 lines
267 KiB
Rust
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()
|
|
);
|
|
}
|