mirror of
https://github.com/Dicklesworthstone/coding_agent_session_search.git
synced 2026-09-22 11:59:25 +08:00
2f16a02985
Publish cass.pack.v2: keep search's one-based line_number unchanged as
citation.message_index and emit message_index_base=1. Version 1 subtracted one,
allowing a copied citation to select the preceding real archived message. Keep
physical spans separately verified; include schema identity in JSONL and explicit
canonical selectors in Markdown without bypassing privacy redaction.
Count sessions by source/path/conversation, scope physical-span deduplication to
source identity, and bind evidence/candidate IDs to length-prefixed canonical
identity. Preserve intentional content deduplication and deterministic ordering.
Add five regressions, including real pack-to-view/expand round trips across five
output projections, shared paths, a missing source file and a plausible wrong
preceding message. Preserve all 24 existing tests and their concurrent expanded
serializer check. Extend the permanent runner with pack aliases, error goldens
and the existing real archive handoff journey.
Exact-source preparation, pinned rustfmt and diff checks passed; downloaded
candidate hashes match reviewed bytes. Native pack run 35680926109 is still
executing. Previous canonical/physical bound suites passed, but current strict
Clippy and full runtime qualification are not claimed. RCH/UBS unavailable
locally. A combined candidate refused concurrent changes; none were overwritten.
Restore candidate publishing to read-only. Reparent the reviewed changes onto
631ceadd after a safe non-fast-forward refusal, preserving its MCP preparation.
Direct main via GitHub, no PR or force. Related: coding_agent_session_search-2l1b0.20.
8850 lines
294 KiB
Rust
8850 lines
294 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");
|
|
// WS-A.6 (2w1sc): 52 tests in this binary read the committed fixture
|
|
// archive IN PLACE (`tests/fixtures/search_demo_data`). Stale-on-read
|
|
// auto-refresh only stays quiet for data dirs under the OS temp dir, and
|
|
// the fixture is under the checkout, so on a fleet worker a `search` here
|
|
// spawned a detached `cass index --background` INTO the fixture, which
|
|
// ingested that worker's real sessions; every golden test that later
|
|
// copied the fixture absorbed them. These tests observe a fixture; they
|
|
// never want a live refresh.
|
|
cmd.env("CASS_AUTO_REFRESH", "0");
|
|
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.")
|
|
// The lexical self-heal's archive-fingerprint sidecar is a
|
|
// derived cache written next to the index through a
|
|
// `.<pid>.tmp` file and a rename. In-place robot tests running
|
|
// in parallel with a fixture clone make that temp file vanish
|
|
// between the directory walk and the copy (bead zgzva); the
|
|
// clone never needs it — a copied archive re-derives it.
|
|
|| name.starts_with(".archive-fingerprint-cache.json")
|
|
})
|
|
})
|
|
}
|
|
|
|
/// frankensqlite VFS namespace lock sidecars appear next to any database the
|
|
/// moment a local test run opens the committed fixture DB. They carry
|
|
/// machine-local lock state: cloning them into the isolated tempdir makes
|
|
/// the copied database fail its readonly open, so the fixture clone must
|
|
/// never carry them (they are also gitignored).
|
|
fn is_fsqlite_runtime_lock_sidecar_path(path: &Path) -> bool {
|
|
path.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.is_some_and(|name| name.ends_with("-fsqlite-ns-gate") || name.ends_with("-fsqlite-ns-use"))
|
|
}
|
|
|
|
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)),
|
|
};
|
|
if is_fsqlite_runtime_lock_sidecar_path(entry.path()) {
|
|
continue;
|
|
}
|
|
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 coding_agent_search::franken_sync::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 = coding_agent_search::franken_sync::Connection::open(
|
|
db_path.to_string_lossy().into_owned(),
|
|
)?;
|
|
conn.execute_compat(
|
|
"UPDATE workspaces SET path = ?1 WHERE id = 1",
|
|
coding_agent_search::franken_sync::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");
|
|
|
|
hold_lexical_rebuild_lock_only(data_dir, db_path)
|
|
}
|
|
|
|
fn hold_lexical_rebuild_lock_only(data_dir: &Path, db_path: &Path) -> fs::File {
|
|
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("OMP aliases: oh-my-pi | oh_my_pi | ohmypi"))
|
|
.stdout(contains("Exit codes: 0 ok"));
|
|
}
|
|
|
|
#[test]
|
|
fn resume_help_lists_every_omp_alias() {
|
|
let mut cmd = base_cmd();
|
|
cmd.args(["resume", "--help"]);
|
|
cmd.assert()
|
|
.success()
|
|
.stdout(contains("omp | oh-my-pi | oh_my_pi | ohmypi"));
|
|
}
|
|
|
|
#[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",
|
|
"CASS_TRACE_FILTER",
|
|
"CASS_TRACE_MAX_BYTES",
|
|
"CASS_TRACE_MAX_EVENTS",
|
|
"CASS_OMP_DATA_ROOT",
|
|
"PI_SESSIONS_DIR",
|
|
"CASS_STATUS_BUDGET_MS",
|
|
"CASS_DOCTOR_BUDGET_MS",
|
|
"CASS_FTS_DRYRUN_CAP",
|
|
"CASS_VIEW_BUDGET_MS",
|
|
"CASS_SEARCH_BUDGET_MS",
|
|
"CASS_TRIAGE_BUDGET_MS",
|
|
"CASS_PACK_BUDGET_MS",
|
|
"CASS_FLEET_PER_HOST_BUDGET_MS",
|
|
"CASS_FLEET_BUDGET_MS",
|
|
] {
|
|
assert!(
|
|
env_vars.iter().any(|env_var| env_var["name"] == expected),
|
|
"capabilities should include env var {expected}"
|
|
);
|
|
}
|
|
for (name, expected_default) in [
|
|
("CASS_STATUS_BUDGET_MS", "8000"),
|
|
("CASS_DOCTOR_BUDGET_MS", "8000"),
|
|
("CASS_FTS_DRYRUN_CAP", "4096"),
|
|
("CASS_VIEW_BUDGET_MS", "10000"),
|
|
("CASS_SEARCH_BUDGET_MS", "120000"),
|
|
("CASS_TRIAGE_BUDGET_MS", "8000"),
|
|
("CASS_PACK_BUDGET_MS", "10000"),
|
|
("CASS_FLEET_PER_HOST_BUDGET_MS", "8000"),
|
|
("CASS_FLEET_BUDGET_MS", "60000"),
|
|
] {
|
|
let capability = env_vars
|
|
.iter()
|
|
.find(|env_var| env_var["name"] == name)
|
|
.expect("budget environment variable should be advertised");
|
|
assert_eq!(
|
|
capability["default"].as_str(),
|
|
Some(expected_default),
|
|
"capabilities should advertise the runtime default for {name}"
|
|
);
|
|
}
|
|
let pi_sessions_dir = env_vars
|
|
.iter()
|
|
.find(|env_var| env_var["name"] == "PI_SESSIONS_DIR")
|
|
.expect("PI_SESSIONS_DIR capability");
|
|
assert_eq!(
|
|
pi_sessions_dir["description"], "Override the exact Pi Agent sessions directory.",
|
|
"capabilities must distinguish the exact sessions override from the broader Pi-family agent directory"
|
|
);
|
|
let omp_data_root = env_vars
|
|
.iter()
|
|
.find(|env_var| env_var["name"] == "CASS_OMP_DATA_ROOT")
|
|
.expect("CASS_OMP_DATA_ROOT capability");
|
|
assert!(
|
|
omp_data_root["description"]
|
|
.as_str()
|
|
.is_some_and(|description| description.contains("OMP-only")),
|
|
"capabilities must expose the provider-qualified OMP ownership override"
|
|
);
|
|
let shared_pi_family_root = env_vars
|
|
.iter()
|
|
.find(|env_var| env_var["name"] == "PI_CODING_AGENT_DIR")
|
|
.expect("PI_CODING_AGENT_DIR capability");
|
|
assert!(
|
|
shared_pi_family_root["description"]
|
|
.as_str()
|
|
.is_some_and(|description| description.contains("Pi Agent-owned")),
|
|
"the ambiguous shared override must not promise OMP identity"
|
|
);
|
|
|
|
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 --message-index 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_command_returns_evidence(command: &str, extra_args: &[&str]) {
|
|
// The legacy demo archive has no auth evidence. Index an explicit source
|
|
// so each alias must return a real message, not just a well-shaped envelope.
|
|
let fixture = TempDir::new().expect("isolated pack alias home");
|
|
let home = fixture.path();
|
|
let data_dir = home.join("cass_data");
|
|
let codex_home = home.join(".codex");
|
|
let filename = "rollout-pack-alias.jsonl";
|
|
util::seed_codex_session(&codex_home, filename, "auth alias evidence", false);
|
|
let source_path = codex_home.join("sessions/2026/04/23").join(filename);
|
|
let mut index = isolated_cass_cmd(home);
|
|
for (key, relative) in [
|
|
("CLAUDE_HOME", ".claude"),
|
|
("GEMINI_HOME", ".gemini"),
|
|
("OPENCODE_STORAGE_ROOT", ".opencode"),
|
|
("CASS_AIDER_DATA_ROOT", ".aider-missing"),
|
|
("PI_SESSIONS_DIR", ".pi-sessions-missing"),
|
|
("PI_CODING_AGENT_DIR", ".pi-agent-missing"),
|
|
(
|
|
"PI_CODING_AGENT_SESSION_DIR",
|
|
".pi-coding-agent-sessions-missing",
|
|
),
|
|
] {
|
|
index.env(key, home.join(relative));
|
|
}
|
|
index
|
|
.env_remove("PI_CONFIG_DIR")
|
|
.env_remove("PI_PROFILE")
|
|
.env("CASS_AUTO_REFRESH", "0")
|
|
.args(["index", "--full", "--json", "--data-dir"])
|
|
.arg(&data_dir)
|
|
.timeout(std::time::Duration::from_secs(120))
|
|
.assert()
|
|
.success();
|
|
let mut cmd = isolated_cass_cmd(home);
|
|
cmd.args([command, "auth", "--json", "--data-dir"]);
|
|
cmd.arg(&data_dir);
|
|
cmd.args([
|
|
"--limit",
|
|
"1",
|
|
"--max-evidence",
|
|
"1",
|
|
"--max-sessions",
|
|
"1",
|
|
"--require-evidence",
|
|
]);
|
|
cmd.args(extra_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 pack JSON");
|
|
|
|
assert_eq!(json["schema_version"].as_str(), Some("cass.pack.v2"));
|
|
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));
|
|
assert_eq!(json["evidence"].as_array().expect("pack evidence").len(), 1);
|
|
assert_eq!(json["evidence"][0]["excerpt"], "auth alias evidence");
|
|
let citation = &json["evidence"][0]["citation"];
|
|
assert_eq!(
|
|
Path::new(citation["source_path"].as_str().expect("source path")),
|
|
source_path
|
|
);
|
|
assert_eq!(citation["verified"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn answer_alias_runs_pack_command() {
|
|
assert_pack_command_returns_evidence("answer", &[]);
|
|
}
|
|
|
|
#[test]
|
|
fn handoff_alias_runs_pack_command() {
|
|
assert_pack_command_returns_evidence("handoff", &[]);
|
|
}
|
|
|
|
#[test]
|
|
fn why_alias_runs_pack_command() {
|
|
assert_pack_command_returns_evidence("why", &[]);
|
|
}
|
|
|
|
#[test]
|
|
fn explain_alias_runs_pack_command() {
|
|
assert_pack_command_returns_evidence("explain", &[]);
|
|
}
|
|
|
|
#[test]
|
|
fn rca_alias_runs_pack_command() {
|
|
assert_pack_command_returns_evidence("rca", &[]);
|
|
}
|
|
|
|
#[test]
|
|
fn pack_contract_field_masks_return_real_cited_evidence() {
|
|
for preset in ["standard", "full"] {
|
|
assert_pack_command_returns_evidence("pack", &["--field-mask", preset]);
|
|
}
|
|
}
|
|
|
|
#[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_preserves_legacy_raw_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_search_hit_assignments_require_an_archive_instead_of_reading_raw_lines() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let missing = tmp.path().join("missing.db");
|
|
for fields in [
|
|
vec!["README.md", "line_number=1", "context=0"],
|
|
vec![
|
|
"source_path=README.md",
|
|
"source_id=local",
|
|
"line_number=1",
|
|
"context=0",
|
|
],
|
|
] {
|
|
let mut cmd = base_cmd();
|
|
cmd.arg("--db")
|
|
.arg(&missing)
|
|
.arg("view")
|
|
.args(fields)
|
|
.arg("--json");
|
|
let output = cmd.assert().failure().get_output().clone();
|
|
assert!(output.stdout.is_empty(), "must not emit a raw-line target");
|
|
let error: Value = serde_json::from_slice(&output.stderr).expect("structured error");
|
|
assert_eq!(error["error"]["kind"], "indexed-session-required");
|
|
assert!(
|
|
!missing.exists(),
|
|
"read-only lookup must not create an archive"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
|
|
let status_healthy = status_json.pointer("/healthy").ok_or_else(|| {
|
|
std::io::Error::new(ErrorKind::InvalidData, "status response missing /healthy")
|
|
})?;
|
|
let state_healthy = state_json.pointer("/healthy").ok_or_else(|| {
|
|
std::io::Error::new(ErrorKind::InvalidData, "state response missing /healthy")
|
|
})?;
|
|
let status_pending_sessions = status_json.pointer("/pending/sessions").ok_or_else(|| {
|
|
std::io::Error::new(
|
|
ErrorKind::InvalidData,
|
|
"status response missing /pending/sessions",
|
|
)
|
|
})?;
|
|
let state_pending_sessions = state_json.pointer("/pending/sessions").ok_or_else(|| {
|
|
std::io::Error::new(
|
|
ErrorKind::InvalidData,
|
|
"state response missing /pending/sessions",
|
|
)
|
|
})?;
|
|
let status_semantic = status_json.pointer("/semantic").ok_or_else(|| {
|
|
std::io::Error::new(ErrorKind::InvalidData, "status response missing /semantic")
|
|
})?;
|
|
let state_semantic = state_json.pointer("/semantic").ok_or_else(|| {
|
|
std::io::Error::new(ErrorKind::InvalidData, "state response missing /semantic")
|
|
})?;
|
|
|
|
// Core assertion: status and state report the same health.
|
|
assert_eq!(status_healthy, state_healthy);
|
|
// Pending sessions should match between the two commands, regardless of the
|
|
// rebuild/watch state observed in the fixture dataset.
|
|
assert_eq!(status_pending_sessions, state_pending_sessions);
|
|
assert_eq!(status_semantic, state_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",
|
|
"CASS_STATUS_BUDGET_MS",
|
|
"CASS_DOCTOR_BUDGET_MS",
|
|
"CASS_FTS_DRYRUN_CAP",
|
|
"CASS_VIEW_BUDGET_MS",
|
|
"CASS_SEARCH_BUDGET_MS",
|
|
"CASS_TRIAGE_BUDGET_MS",
|
|
"CASS_PACK_BUDGET_MS",
|
|
"CASS_FLEET_PER_HOST_BUDGET_MS",
|
|
"CASS_FLEET_BUDGET_MS",
|
|
] {
|
|
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 mut 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"
|
|
);
|
|
|
|
// GH #399: build identity varies per build/checkout. Validate its shape,
|
|
// then normalize to the golden placeholders before the exact compare.
|
|
let commit = actual["build_commit"]
|
|
.as_str()
|
|
.expect("build_commit should be a string");
|
|
let commit_core = commit.strip_suffix("-dirty").unwrap_or(commit);
|
|
assert!(
|
|
commit_core == "unknown"
|
|
|| (commit_core.len() == 12 && commit_core.chars().all(|c| c.is_ascii_hexdigit())),
|
|
"build_commit should be a 12-hex short sha (optionally -dirty) or 'unknown', got: {commit}"
|
|
);
|
|
assert!(
|
|
actual["build_commit_date"].is_string(),
|
|
"build_commit_date should be a string"
|
|
);
|
|
actual["build_commit"] = Value::String("[BUILD_COMMIT]".to_string());
|
|
actual["build_commit_date"] = Value::String("[BUILD_COMMIT_DATE]".to_string());
|
|
|
|
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["fields"]["exit_code"]
|
|
.as_i64()
|
|
.expect("exit_code present");
|
|
assert_eq!(
|
|
(json["schema_version"].as_str(), exit_code),
|
|
(Some("cass-trace-v1"), code as i64)
|
|
);
|
|
assert_eq!(json["fields"]["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["schema_version"].as_str(),
|
|
json["fields"]["exit_code"].as_i64()
|
|
),
|
|
(Some("cass-trace-v1"), Some(0)),
|
|
"Successful search should have exit_code 0"
|
|
);
|
|
assert_eq!(json["fields"]["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");
|
|
util::prepare_copied_search_fixture(data_dir.path())?;
|
|
let index_path = coding_agent_search::search::tantivy::expected_index_dir(data_dir.path());
|
|
assert!(index_path.join("MANIFEST").is_file());
|
|
let checkpoint_path = index_path.join(".lexical-rebuild-state.json");
|
|
let checkpoint_bytes = fs::read(&checkpoint_path)?;
|
|
let checkpoint: Value = serde_json::from_slice(&checkpoint_bytes)?;
|
|
assert_eq!(
|
|
checkpoint["completed"], true,
|
|
"real fixture repair must finish"
|
|
);
|
|
|
|
let mut baseline = base_cmd();
|
|
baseline.args([
|
|
"search",
|
|
"hello",
|
|
"--json",
|
|
"--mode",
|
|
"lexical",
|
|
"--no-maintenance",
|
|
"--limit",
|
|
"1",
|
|
"--data-dir",
|
|
data_dir.path().to_str().expect("utf8 fixture path"),
|
|
]);
|
|
let baseline_output = baseline.assert().success().get_output().clone();
|
|
let baseline_json: Value = serde_json::from_slice(&baseline_output.stdout)?;
|
|
assert!(
|
|
!baseline_json["hits"]
|
|
.as_array()
|
|
.expect("baseline hits")
|
|
.is_empty()
|
|
);
|
|
let _lock = hold_lexical_rebuild_lock_only(data_dir.path(), &db_path);
|
|
|
|
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");
|
|
assert!(!json["hits"].as_array().expect("search hits").is_empty());
|
|
assert_eq!(fs::read(&checkpoint_path)?, checkpoint_bytes);
|
|
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", "omp", "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]));
|
|
}
|
|
|
|
/// README contract: when cass auto-corrects a robot invocation it emits a
|
|
/// teaching note on stderr so the agent learns the canonical syntax, while
|
|
/// stdout stays data-only. Positive observable: the note names the
|
|
/// correction. Planted negative: a canonical invocation prints no note.
|
|
#[test]
|
|
fn robot_mode_auto_correction_emits_teaching_note_on_stderr() -> Result<(), Box<dyn Error>> {
|
|
let fixture = isolated_search_demo_data()?;
|
|
let data_dir = fixture.path().to_str().ok_or("non-utf8 data dir")?;
|
|
|
|
let corrected = base_cmd()
|
|
.args(["find", "hello", "--json", "--data-dir", data_dir])
|
|
.output()?;
|
|
let stderr = String::from_utf8_lossy(&corrected.stderr);
|
|
assert!(
|
|
stderr.contains("note: auto-corrected:"),
|
|
"robot-mode correction must teach on stderr; got stderr: {stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("Tip: Run 'cass --help'"),
|
|
"robot-mode note must be the compact machine form, not the human tip block: {stderr}"
|
|
);
|
|
let stdout = String::from_utf8_lossy(&corrected.stdout);
|
|
let _: Value = serde_json::from_str(stdout.trim())
|
|
.map_err(|e| format!("stdout must stay a single JSON document: {e}; stdout={stdout}"))?;
|
|
|
|
let canonical = base_cmd()
|
|
.args(["search", "hello", "--json", "--data-dir", data_dir])
|
|
.output()?;
|
|
let canonical_stderr = String::from_utf8_lossy(&canonical.stderr);
|
|
assert!(
|
|
!canonical_stderr.contains("auto-corrected"),
|
|
"a canonical invocation must not print a correction note: {canonical_stderr}"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// 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 fixture = isolated_search_demo_data().expect("isolated implicit pack fixture");
|
|
util::prepare_copied_search_fixture(fixture.path()).expect("admit relocated pack fixture");
|
|
let mut cmd = base_cmd();
|
|
cmd.args([
|
|
"auth",
|
|
"failed",
|
|
"--json",
|
|
"--data-dir",
|
|
fixture.path().to_str().unwrap(),
|
|
"--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.v2"));
|
|
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_returns_bounded_partial_and_names_shed_work() -> Result<(), Box<dyn Error>>
|
|
{
|
|
let data_dir = isolated_search_demo_data()?;
|
|
util::prepare_copied_search_fixture(data_dir.path())?;
|
|
let (stall_ms, guard) = pack_stall_and_guard_from_baseline(data_dir.path())?;
|
|
let started = std::time::Instant::now();
|
|
let output = base_cmd()
|
|
.env("CASS_PACK_BUDGET_MS", "100")
|
|
.env("CASS_TEST_PACK_SLOW_MS", stall_ms.to_string())
|
|
.args([
|
|
"pack",
|
|
"hello",
|
|
"--json",
|
|
"--mode",
|
|
"lexical",
|
|
"--agent",
|
|
"codex",
|
|
"--source",
|
|
"local",
|
|
"--limit",
|
|
"7",
|
|
"--explain-selection",
|
|
"--include-skill-content",
|
|
"--data-dir",
|
|
data_dir.path().to_str().ok_or("non-utf8 data dir")?,
|
|
])
|
|
.output()?;
|
|
if started.elapsed() >= guard {
|
|
return Err(format!(
|
|
"pack command waited for the simulated {stall_ms} ms search stall instead of \
|
|
stopping at its 100 ms budget"
|
|
)
|
|
.into());
|
|
}
|
|
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_none_or(|evidence| !evidence.is_empty())
|
|
{
|
|
return Err("timed-out core search must not invent completed evidence".into());
|
|
}
|
|
if payload["query"]["text"] != "hello" {
|
|
return Err("pack timeout discarded the requested query identity".into());
|
|
}
|
|
if payload["privacy"]["skill_content_included"] != false {
|
|
return Err("timed-out pack claimed to include skill content without evidence".into());
|
|
}
|
|
if !["search", "selection_explanations"].iter().all(|expected| {
|
|
skipped
|
|
.iter()
|
|
.any(|section| section.as_str() == Some(*expected))
|
|
}) {
|
|
return Err(format!("pack timeout omitted shed work: {budget}").into());
|
|
}
|
|
if payload["_meta"]["partial"] != true
|
|
|| !budget["recommended_next_probe"]
|
|
.as_str()
|
|
.is_some_and(|probe| {
|
|
probe.starts_with("cass pack ")
|
|
&& probe.contains("--agent codex")
|
|
&& probe.contains("--source local")
|
|
&& probe.contains("--limit 7")
|
|
&& probe.contains("--include-skill-content")
|
|
&& probe.contains("--data-dir")
|
|
})
|
|
{
|
|
return Err(format!("pack partial metadata is inconsistent: {payload}").into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn timed_out_robot_pack_setup_returns_bounded_partial_before_asset_validation_finishes()
|
|
-> Result<(), Box<dyn Error>> {
|
|
let data_dir = isolated_search_demo_data()?;
|
|
let started = std::time::Instant::now();
|
|
let output = base_cmd()
|
|
.env("CASS_TEST_SEARCH_SETUP_SLOW_MS", "2000")
|
|
.args([
|
|
"pack",
|
|
"hello",
|
|
"--json",
|
|
"--mode",
|
|
"lexical",
|
|
"--timeout",
|
|
"120",
|
|
"--data-dir",
|
|
data_dir.path().to_str().ok_or("non-utf8 data dir")?,
|
|
])
|
|
.output()?;
|
|
if started.elapsed() >= std::time::Duration::from_millis(1500) {
|
|
return Err("pack waited for the simulated two-second search-setup stall".into());
|
|
}
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"timed-out pack setup 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 setup skipped_sections is not an array")?;
|
|
if budget["timed_out"] != true || budget["budget_ms"] != 120 {
|
|
return Err(format!("pack setup timeout metadata was false: {budget}").into());
|
|
}
|
|
for section in ["search_setup", "search"] {
|
|
if !skipped.iter().any(|value| value == section) {
|
|
return Err(format!("pack setup timeout omitted {section}: {budget}").into());
|
|
}
|
|
}
|
|
if payload["query"]["text"] != "hello"
|
|
|| payload["evidence"]
|
|
.as_array()
|
|
.is_none_or(|evidence| !evidence.is_empty())
|
|
|| payload["_meta"]["partial"] != true
|
|
{
|
|
return Err(format!(
|
|
"pack setup timeout lost request identity or fabricated evidence: {payload}"
|
|
)
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn blocking_sessions_file_pack_returns_bounded_partial_without_fabricated_evidence()
|
|
-> Result<(), Box<dyn Error>> {
|
|
let data_dir = isolated_search_demo_data()?;
|
|
let sessions_fifo = data_dir.path().join("pack-sessions.fifo");
|
|
let mkfifo = std::process::Command::new("mkfifo")
|
|
.arg(&sessions_fifo)
|
|
.status()?;
|
|
if !mkfifo.success() {
|
|
return Err(format!("mkfifo failed with status {mkfifo:?}").into());
|
|
}
|
|
|
|
let started = std::time::Instant::now();
|
|
let output = base_cmd()
|
|
.timeout(std::time::Duration::from_secs(3))
|
|
.args([
|
|
"pack",
|
|
"hello",
|
|
"--json",
|
|
"--mode",
|
|
"lexical",
|
|
"--timeout",
|
|
"120",
|
|
"--sessions-from",
|
|
])
|
|
.arg(&sessions_fifo)
|
|
.args(["--data-dir"])
|
|
.arg(data_dir.path())
|
|
.output()?;
|
|
if started.elapsed() >= std::time::Duration::from_millis(1500) {
|
|
return Err("pack waited for the blocking sessions FIFO".into());
|
|
}
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"FIFO-scoped 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 FIFO skipped_sections is not an array")?;
|
|
if budget["timed_out"] != true {
|
|
return Err(format!("pack FIFO timeout was not reported: {budget}").into());
|
|
}
|
|
for section in ["sessions_from", "search"] {
|
|
if !skipped.iter().any(|value| value == section) {
|
|
return Err(format!("pack FIFO timeout omitted {section}: {budget}").into());
|
|
}
|
|
}
|
|
if payload["evidence"]
|
|
.as_array()
|
|
.is_none_or(|evidence| !evidence.is_empty())
|
|
{
|
|
return Err(format!("pack FIFO timeout fabricated evidence: {payload}").into());
|
|
}
|
|
let recommendation = budget["recommended_next_probe"]
|
|
.as_str()
|
|
.ok_or("pack FIFO timeout omitted its file-scope retry")?;
|
|
if !recommendation.contains("--sessions-from")
|
|
|| !recommendation.contains(&sessions_fifo.display().to_string())
|
|
{
|
|
return Err(
|
|
format!("pack FIFO retry lost its session file scope: {recommendation}").into(),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Budget for the pack timeout tests, derived from an unstalled run on the
|
|
/// same fixture. The timeout must expire inside the injected planner/render
|
|
/// stall, not in `search_setup`: on the debug-build fleet the archive open
|
|
/// alone took anywhere from under 0.5 s to over 2 s across runs, so any fixed
|
|
/// budget was wrong on some worker (bead zgzva). Returns `(budget_ms,
|
|
/// stall_ms)`: three times the unstalled wall time (at least 2 s) and a stall
|
|
/// of three budgets, so a run that waited the stall out is unmistakable.
|
|
fn pack_timeout_budget_from_baseline(
|
|
data_dir: &std::path::Path,
|
|
) -> Result<(u64, u64), Box<dyn Error>> {
|
|
let baseline_ms = pack_unstalled_baseline_ms(data_dir)?;
|
|
let budget_ms = (baseline_ms.saturating_mul(3)).max(2_000);
|
|
Ok((budget_ms, budget_ms.saturating_mul(3)))
|
|
}
|
|
|
|
/// Wall time of one unstalled `cass pack hello --mode lexical` on `data_dir`,
|
|
/// measured right before a stalled run so the stalled run's guard is relative
|
|
/// to the worker's speed at that moment rather than to a fixed number.
|
|
fn pack_unstalled_baseline_ms(data_dir: &std::path::Path) -> Result<u64, Box<dyn Error>> {
|
|
let started = std::time::Instant::now();
|
|
let output = base_cmd()
|
|
.args([
|
|
"pack",
|
|
"hello",
|
|
"--json",
|
|
"--mode",
|
|
"lexical",
|
|
"--data-dir",
|
|
data_dir.to_str().ok_or("non-utf8 data dir")?,
|
|
])
|
|
.output()?;
|
|
let baseline_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"baseline pack run failed: status={:?}; stderr={}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stderr)
|
|
)
|
|
.into());
|
|
}
|
|
Ok(baseline_ms)
|
|
}
|
|
|
|
/// Stall and return-guard for a pack test that injects a stall the budget is
|
|
/// expected to cut short: the stall is three unstalled baselines (at least
|
|
/// 2 s) and the guard is one baseline plus half the stall, so a command that
|
|
/// waited the stall out cannot pass and a bounded one on a loaded worker can.
|
|
fn pack_stall_and_guard_from_baseline(
|
|
data_dir: &std::path::Path,
|
|
) -> Result<(u64, std::time::Duration), Box<dyn Error>> {
|
|
let baseline_ms = pack_unstalled_baseline_ms(data_dir)?;
|
|
let stall_ms = (baseline_ms.saturating_mul(3)).max(2_000);
|
|
let guard = std::time::Duration::from_millis(baseline_ms.saturating_add(stall_ms / 2));
|
|
Ok((stall_ms, guard))
|
|
}
|
|
|
|
#[test]
|
|
fn timed_out_robot_pack_renderer_emits_fixed_size_partial_fallback() -> Result<(), Box<dyn Error>> {
|
|
let data_dir = isolated_search_demo_data()?;
|
|
util::prepare_copied_search_fixture(data_dir.path())?;
|
|
let (budget_ms, stall_ms) = pack_timeout_budget_from_baseline(data_dir.path())?;
|
|
let started = std::time::Instant::now();
|
|
let output = base_cmd()
|
|
.env("CASS_TEST_PACK_RENDER_SLOW_MS", stall_ms.to_string())
|
|
.args([
|
|
"pack",
|
|
"hello",
|
|
"--json",
|
|
"--mode",
|
|
"lexical",
|
|
"--timeout",
|
|
&budget_ms.to_string(),
|
|
"--data-dir",
|
|
data_dir.path().to_str().ok_or("non-utf8 data dir")?,
|
|
])
|
|
.output()?;
|
|
if started.elapsed() >= std::time::Duration::from_millis(budget_ms + stall_ms / 2) {
|
|
return Err(format!(
|
|
"pack command waited for the simulated {stall_ms} ms render stall instead of \
|
|
stopping at its {budget_ms} ms budget"
|
|
)
|
|
.into());
|
|
}
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"timed-out pack renderer 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"];
|
|
if budget["timed_out"] != true
|
|
|| !budget["skipped_sections"]
|
|
.as_array()
|
|
.is_some_and(|sections| {
|
|
sections
|
|
.iter()
|
|
.any(|section| section == "trust_correlation")
|
|
&& sections
|
|
.iter()
|
|
.any(|section| section == "answer_pack_render")
|
|
})
|
|
{
|
|
return Err(format!(
|
|
"pack renderer timeout did not name the advisory work it shed: {budget}"
|
|
)
|
|
.into());
|
|
}
|
|
if payload["realized"]["candidate_count"]
|
|
.as_u64()
|
|
.is_none_or(|count| count == 0)
|
|
|| payload["realized"]["selected_evidence_count"] != 0
|
|
|| payload["evidence"]
|
|
.as_array()
|
|
.is_none_or(|evidence| !evidence.is_empty())
|
|
{
|
|
return Err(format!(
|
|
"pack renderer timeout must retain candidate accounting without traversing the timed-out evidence render: {payload}"
|
|
)
|
|
.into());
|
|
}
|
|
if payload["_meta"]["partial"] != true
|
|
|| !budget["recommended_next_probe"]
|
|
.as_str()
|
|
.is_some_and(|probe| {
|
|
probe.starts_with("cass pack ")
|
|
&& probe.contains("--data-dir")
|
|
// The retry doubles the budget that timed out.
|
|
&& probe.contains(&format!("--timeout {}", budget_ms * 2))
|
|
})
|
|
{
|
|
return Err(format!(
|
|
"pack renderer timeout emitted inconsistent partial metadata: {payload}"
|
|
)
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn timed_out_robot_pack_planner_does_not_fabricate_selection() -> Result<(), Box<dyn Error>> {
|
|
let data_dir = isolated_search_demo_data()?;
|
|
util::prepare_copied_search_fixture(data_dir.path())?;
|
|
let (budget_ms, stall_ms) = pack_timeout_budget_from_baseline(data_dir.path())?;
|
|
let started = std::time::Instant::now();
|
|
let output = base_cmd()
|
|
.env("CASS_TEST_PACK_PLAN_SLOW_MS", stall_ms.to_string())
|
|
.args([
|
|
"pack",
|
|
"hello",
|
|
"--json",
|
|
"--mode",
|
|
"lexical",
|
|
"--timeout",
|
|
&budget_ms.to_string(),
|
|
"--data-dir",
|
|
data_dir.path().to_str().ok_or("non-utf8 data dir")?,
|
|
])
|
|
.output()?;
|
|
if started.elapsed() >= std::time::Duration::from_millis(budget_ms + stall_ms / 2) {
|
|
return Err(format!(
|
|
"pack command waited for the simulated {stall_ms} ms planner stall instead of \
|
|
stopping at its {budget_ms} ms budget"
|
|
)
|
|
.into());
|
|
}
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"timed-out pack planner 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"];
|
|
if budget["timed_out"] != true
|
|
|| !budget["skipped_sections"]
|
|
.as_array()
|
|
.is_some_and(|sections| sections.iter().any(|section| section == "pack_planning"))
|
|
{
|
|
return Err(
|
|
format!("pack planner timeout did not name the work it skipped: {budget}").into(),
|
|
);
|
|
}
|
|
if payload["realized"]["candidate_count"]
|
|
.as_u64()
|
|
.is_none_or(|count| count == 0)
|
|
|| payload["realized"]["selected_evidence_count"] != 0
|
|
|| payload["evidence"]
|
|
.as_array()
|
|
.is_none_or(|evidence| !evidence.is_empty())
|
|
{
|
|
return Err(format!(
|
|
"pack planner timeout fabricated or discarded candidate accounting: {payload}"
|
|
)
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_search_pack_only_flags_run_pack_in_robot_mode() {
|
|
let fixture = isolated_search_demo_data().expect("isolated explicit pack fixture");
|
|
util::prepare_copied_search_fixture(fixture.path()).expect("admit relocated pack fixture");
|
|
for command in ["search", "find"] {
|
|
let mut cmd = base_cmd();
|
|
cmd.args([
|
|
command,
|
|
"auth",
|
|
"failed",
|
|
"--json",
|
|
"--data-dir",
|
|
fixture.path().to_str().unwrap(),
|
|
"--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.v2"));
|
|
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["schema_version"].as_str(),
|
|
json["fields"]["exit_code"].as_i64()
|
|
),
|
|
(Some("cass-trace-v1"), Some(0)),
|
|
"exit_code should be 0 for success"
|
|
);
|
|
assert_eq!(
|
|
json["fields"]["contract_version"], "1",
|
|
"contract_version should be 1"
|
|
);
|
|
// Trace uses start_ts and end_ts for timestamps
|
|
assert!(
|
|
json["fields"]["start_ts"].is_string() || json["fields"]["end_ts"].is_string(),
|
|
"timestamp (start_ts/end_ts) should be present"
|
|
);
|
|
assert!(
|
|
json["fields"]["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["fields"]["exit_code"].as_i64().expect("exit_code");
|
|
assert_ne!(exit_code, 0, "exit_code should be non-zero for failure");
|
|
assert_eq!(
|
|
(
|
|
json["schema_version"].as_str(),
|
|
json["fields"]["contract_version"].as_str()
|
|
),
|
|
(Some("cass-trace-v1"), Some("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 now matches `cass status` / DEFAULT_STALE_THRESHOLD_SECS so a
|
|
// bare `cass health` does not flip unhealthy five minutes after a rebuild
|
|
// on large archives outside continuous watch mode.
|
|
assert_eq!(
|
|
stale["default"], "1800",
|
|
"health --stale-threshold should default to 1800 (30 minutes), matching status"
|
|
);
|
|
}
|
|
|
|
/// 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 is selected by runtime policy when omitted.
|
|
#[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!(embedder["default"].is_null());
|
|
}
|
|
|
|
/// 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_deref(), Some("fastembed"));
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Index command parsing must distinguish an omitted embedder from an explicit alias.
|
|
#[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!(embedder.is_none());
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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 parse_error = serde_json::from_str::<Value>(stdout.trim()).err();
|
|
assert!(
|
|
parse_error.is_none(),
|
|
"stdout must be valid JSON in degraded mode; got parse error {parse_error:?}; \
|
|
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()
|
|
);
|
|
}
|
|
|
|
/// #356: an early-exiting pipe consumer (`cass ... | head`) must terminate
|
|
/// cass via the restored default SIGPIPE disposition (signal 13), not a stdio
|
|
/// panic — which unwinds to exit 101 in dev builds and, under the release
|
|
/// profile's panic=abort, becomes a SIGABRT with a crash report on macOS.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn broken_stdout_pipe_terminates_via_sigpipe_not_abort() {
|
|
use std::os::unix::process::ExitStatusExt;
|
|
use std::process::{Command as StdCommand, Stdio};
|
|
|
|
const SIGPIPE: i32 = 13;
|
|
|
|
// `robot-docs schemas` emits well over the 64 KiB pipe capacity and needs
|
|
// no database, so a closed read end deterministically forces an EPIPE
|
|
// write regardless of scheduling.
|
|
let mut child = StdCommand::new(cass_bin())
|
|
.args(["robot-docs", "schemas"])
|
|
.env("CODING_AGENT_SEARCH_NO_UPDATE_PROMPT", "1")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("spawn cass with piped stdout");
|
|
|
|
drop(child.stdout.take());
|
|
|
|
let status = child.wait().expect("wait for cass");
|
|
assert_eq!(
|
|
status.signal(),
|
|
Some(SIGPIPE),
|
|
"expected quiet SIGPIPE termination on a closed stdout pipe, got {status:?}"
|
|
);
|
|
}
|