2026-10-12 15:51:39 +02:00
|
|
|
//! Exercise the executable boundary: exit status, durable indexing, and JSON.
|
|
|
|
|
use std::{
|
|
|
|
|
fs,
|
|
|
|
|
path::PathBuf,
|
|
|
|
|
process::{Command, Output},
|
|
|
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
|
|
|
|
|
|
|
|
|
struct Fixture(PathBuf);
|
|
|
|
|
|
|
|
|
|
impl Fixture {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
let path = std::env::temp_dir().join(format!(
|
|
|
|
|
"searchi-cli-test-{}-{}",
|
|
|
|
|
std::process::id(),
|
|
|
|
|
NEXT_ID.fetch_add(1, Ordering::Relaxed)
|
|
|
|
|
));
|
|
|
|
|
fs::create_dir(&path).unwrap();
|
|
|
|
|
Self(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run(&self, args: &[&str]) -> Output {
|
|
|
|
|
Command::new(env!("CARGO_BIN_EXE_searchi"))
|
|
|
|
|
.current_dir(&self.0)
|
|
|
|
|
.args(args)
|
|
|
|
|
.output()
|
|
|
|
|
.unwrap()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for Fixture {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn indexes_persists_and_returns_search_and_stats_json() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
fs::create_dir(fixture.0.join("documents")).unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
fixture.0.join("documents/contract.txt"),
|
|
|
|
|
"Supplier termination clause requires sixty days notice.",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let indexed = fixture.run(&["index", "--db", "index.db", "--root", "documents"]);
|
|
|
|
|
assert!(
|
|
|
|
|
indexed.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&indexed.stderr)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Each command is a new process, so retrieval proves the index survived exit.
|
|
|
|
|
let searched = fixture.run(&["search", "--db", "index.db", "termination", "--json"]);
|
|
|
|
|
assert!(
|
|
|
|
|
searched.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&searched.stderr)
|
|
|
|
|
);
|
|
|
|
|
let results: serde_json::Value = serde_json::from_slice(&searched.stdout).unwrap();
|
|
|
|
|
let results = results.as_array().unwrap();
|
|
|
|
|
assert_eq!(results.len(), 1);
|
|
|
|
|
assert_eq!(results[0]["filename"], "contract.txt");
|
|
|
|
|
assert!(results[0]["snippet"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.contains("termination"));
|
|
|
|
|
|
2026-10-12 19:16:23 +02:00
|
|
|
for flag in ["--performance", "--debug-performance"] {
|
|
|
|
|
let measured = fixture.run(&["search", "--db", "index.db", "termination", "--json", flag]);
|
|
|
|
|
assert!(
|
|
|
|
|
measured.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&measured.stderr)
|
|
|
|
|
);
|
|
|
|
|
let envelope: serde_json::Value = serde_json::from_slice(&measured.stdout).unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
envelope["results"],
|
|
|
|
|
serde_json::Value::Array(results.clone())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(envelope["metrics"]["result_count"], 1);
|
|
|
|
|
let total = envelope["metrics"]["total_ms"].as_f64().unwrap();
|
|
|
|
|
assert!(total.is_finite() && total >= 0.0);
|
|
|
|
|
if flag == "--performance" {
|
|
|
|
|
assert!(envelope["metrics"]["trace"].is_null());
|
|
|
|
|
} else {
|
|
|
|
|
let spans = envelope["metrics"]["trace"].as_array().unwrap();
|
|
|
|
|
assert!(spans.iter().any(|span| span["name"] == "cli.index_open"));
|
|
|
|
|
assert!(spans.len() > 2);
|
|
|
|
|
for span in spans {
|
|
|
|
|
let start = span["start_ms"].as_f64().unwrap();
|
|
|
|
|
let duration = span["duration_ms"].as_f64().unwrap();
|
|
|
|
|
assert!(start >= 0.0 && duration >= 0.0 && start + duration <= total + 0.001);
|
|
|
|
|
assert!(!span["name"].as_str().unwrap().contains("termination"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 15:51:39 +02:00
|
|
|
let stats = fixture.run(&["stats", "--db", "index.db", "--json"]);
|
|
|
|
|
assert!(stats.status.success());
|
|
|
|
|
let stats: serde_json::Value = serde_json::from_slice(&stats.stdout).unwrap();
|
|
|
|
|
assert_eq!(stats["files"], 1);
|
|
|
|
|
assert_eq!(stats["content_files"], 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn errors_are_nonzero_and_missing_database_is_not_created() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
let missing = fixture.run(&["search", "--db", "missing.db", "invoice", "--json"]);
|
|
|
|
|
assert!(!missing.status.success());
|
|
|
|
|
assert!(String::from_utf8_lossy(&missing.stderr).contains("does not exist"));
|
|
|
|
|
assert!(!fixture.0.join("missing.db").exists());
|
|
|
|
|
assert!(missing.stdout.is_empty());
|
|
|
|
|
|
|
|
|
|
let bad_root = fixture.run(&["index", "--db", "index.db", "--root", "missing-folder"]);
|
|
|
|
|
assert!(!bad_root.status.success());
|
|
|
|
|
assert!(String::from_utf8_lossy(&bad_root.stderr).contains("missing-folder"));
|
|
|
|
|
}
|
2026-10-12 16:07:53 +02:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn exclusions_and_filters_change_real_results() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
fs::create_dir_all(fixture.0.join("documents/private")).unwrap();
|
|
|
|
|
fs::write(fixture.0.join("documents/report.txt"), "quarterly invoice").unwrap();
|
|
|
|
|
fs::write(fixture.0.join("documents/report.md"), "quarterly invoice").unwrap();
|
|
|
|
|
fs::write(fixture.0.join("documents/private/secret.txt"), "invoice").unwrap();
|
|
|
|
|
let indexed = fixture.run(&[
|
|
|
|
|
"index",
|
|
|
|
|
"--db",
|
|
|
|
|
"index.db",
|
|
|
|
|
"--root",
|
|
|
|
|
"documents",
|
|
|
|
|
"--exclude",
|
|
|
|
|
"private",
|
|
|
|
|
]);
|
|
|
|
|
assert!(indexed.status.success());
|
|
|
|
|
let searched = fixture.run(&[
|
|
|
|
|
"search",
|
|
|
|
|
"--db",
|
|
|
|
|
"index.db",
|
|
|
|
|
"invoice",
|
|
|
|
|
"--extension",
|
|
|
|
|
"md",
|
|
|
|
|
"--json",
|
|
|
|
|
]);
|
|
|
|
|
assert!(
|
|
|
|
|
searched.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&searched.stderr)
|
|
|
|
|
);
|
|
|
|
|
let results: serde_json::Value = serde_json::from_slice(&searched.stdout).unwrap();
|
|
|
|
|
assert_eq!(results.as_array().unwrap().len(), 1);
|
|
|
|
|
assert_eq!(results[0]["filename"], "report.md");
|
|
|
|
|
let stats = fixture.run(&["stats", "--db", "index.db", "--json"]);
|
|
|
|
|
let stats: serde_json::Value = serde_json::from_slice(&stats.stdout).unwrap();
|
|
|
|
|
assert_eq!(stats["files"], 2);
|
|
|
|
|
}
|
2026-10-12 21:22:11 +02:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn code_only_filters_real_results_and_finds_exact_symbols() {
|
|
|
|
|
let fixture = Fixture::new();
|
|
|
|
|
fs::create_dir(fixture.0.join("code")).unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
fixture.0.join("code/retry.rs"),
|
|
|
|
|
"pub fn refreshAccessToken() { /* retry failed requests with backoff */ }",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
fixture.0.join("code/notes.txt"),
|
|
|
|
|
"refreshAccessToken retry failed requests with backoff",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let indexed = fixture.run(&["index", "--db", "index.db", "--root", "code"]);
|
|
|
|
|
assert!(
|
|
|
|
|
indexed.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&indexed.stderr)
|
|
|
|
|
);
|
|
|
|
|
let all = fixture.run(&["search", "--db", "index.db", "refreshAccessToken", "--json"]);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
serde_json::from_slice::<serde_json::Value>(&all.stdout)
|
|
|
|
|
.unwrap()
|
|
|
|
|
.as_array()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.len(),
|
|
|
|
|
2
|
|
|
|
|
);
|
|
|
|
|
let code = fixture.run(&[
|
|
|
|
|
"search",
|
|
|
|
|
"--db",
|
|
|
|
|
"index.db",
|
|
|
|
|
"refreshAccessToken",
|
|
|
|
|
"--code-only",
|
|
|
|
|
"--json",
|
|
|
|
|
]);
|
|
|
|
|
assert!(
|
|
|
|
|
code.status.success(),
|
|
|
|
|
"{}",
|
|
|
|
|
String::from_utf8_lossy(&code.stderr)
|
|
|
|
|
);
|
|
|
|
|
let results: serde_json::Value = serde_json::from_slice(&code.stdout).unwrap();
|
|
|
|
|
assert_eq!(results.as_array().unwrap().len(), 1);
|
|
|
|
|
assert_eq!(results[0]["filename"], "retry.rs");
|
|
|
|
|
assert!(results[0]["snippet"]
|
|
|
|
|
.as_str()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.contains("refreshAccessToken"));
|
|
|
|
|
assert_eq!(results[0]["source"]["kind"], "line");
|
|
|
|
|
}
|