use rag_rat_core::index::AnchorHealth;
use rag_rat_core::query::orientation::Orientation;
use rag_rat_core::query::tree::{DirTree, TreeNode};
use super::*;
fn status(latest: Option<&str>, update: bool) -> rag_rat_core::version_check::VersionStatus {
rag_rat_core::version_check::VersionStatus {
current_version: "0.5.0".to_string(),
latest_version: latest.map(str::to_string),
update_available: update,
update_command: "cargo install rag-rat --force".to_string(),
checked_at_ms: latest.map(|_| 1),
}
}
#[test]
fn version_line_nags_when_behind() {
let line = version_line(&status(Some("0.6.0"), true)).expect("a behind status renders");
assert!(line.contains("update available"), "got: {line}");
assert!(line.contains("0.5.0 → 0.6.0"), "states current → latest: {line}");
assert!(line.contains("cargo install rag-rat --force"), "states the update command: {line}");
}
#[test]
fn version_line_is_quiet_when_current() {
let line = version_line(&status(Some("0.5.0"), false)).expect("an up-to-date status renders");
assert!(line.contains("0.5.0 (latest on crates.io)"), "got: {line}");
assert!(!line.contains("update available"), "no nag when current: {line}");
}
#[test]
fn version_line_is_none_when_latest_unknown() {
assert_eq!(version_line(&status(None, false)), None, "no cached check → no line");
}
#[test]
fn version_line_marks_a_local_build_ahead_of_crates_io() {
let line = version_line(&status(Some("0.4.0"), false)).expect("ahead status renders");
assert!(line.contains("ahead of crates.io latest 0.4.0"), "got: {line}");
assert!(!line.contains("(latest on crates.io)"), "must not claim it's the latest: {line}");
}
#[test]
fn session_start_json_without_tool_fields_deserializes() {
let json =
r#"{"hook_event_name":"SessionStart","source":"startup","cwd":"/x","session_id":"s"}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert_eq!(input.hook_event_name.as_deref(), Some("SessionStart"));
assert_eq!(input.source.as_deref(), Some("startup"));
assert_eq!(input.cwd, "/x");
assert!(input.tool_name.is_empty());
assert!(input.tool_input.is_null());
}
#[test]
fn parses_grep_tool_input() {
let json = r#"{"session_id":"s1","cwd":"/repo","hook_event_name":"PreToolUse",
"tool_name":"Grep","tool_input":{"pattern":"watcher_main","path":"crates"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
let search = extract_search(&input).unwrap();
assert_eq!(search.pattern, "watcher_main");
assert_eq!(search.search_path.as_deref(), Some("crates"));
assert_eq!(search.source, "grep_tool");
}
#[test]
fn bash_parser_table() {
let positives = [
("rg watcher_main", "watcher_main", None),
("rg -n 'election retry' crates/", "election retry", Some("crates/")),
("grep -rn foo src", "foo", Some("src")),
("ag --rust frobnicate", "frobnicate", None),
("rg -e 'fn main' --type rust", "fn main", None),
("cd crates && rg spawn_listener", "spawn_listener", None),
("FOO=1 rg spawn_listener", "spawn_listener", None),
("rg -A 3 -B 2 needle haystack/", "needle", Some("haystack/")),
("rg foo | head", "foo", None),
(r#"rg "quoted pattern" src"#, "quoted pattern", Some("src")),
];
for (cmd, pattern, path) in positives {
let got = parse_bash_search(cmd).unwrap_or_else(|| panic!("no match for {cmd}"));
assert_eq!(got.0, pattern, "pattern for {cmd}");
assert_eq!(got.1.as_deref(), path, "path for {cmd}");
}
let negatives = [
"ls -la",
"cargo test",
"rg", "find . -name '*.rs' -exec grep foo {} \\;", "echo `rg foo`", "xargs grep foo", "groups", "git log | rg fix",
"cargo test | grep result",
"gh run view | grep -i error",
"cargo clippy | grep -E warning",
"cargo test |& grep result", "cargo clippy |& rg warning",
];
for cmd in negatives {
assert!(parse_bash_search(cmd).is_none(), "false positive for {cmd}");
}
}
#[test]
fn extract_search_routes_bash_commands() {
let json = r#"{"session_id":"s1","cwd":"/repo","hook_event_name":"PreToolUse",
"tool_name":"Bash","tool_input":{"command":"rg -n watcher_main crates/"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
let search = extract_search(&input).unwrap();
assert_eq!(search.pattern, "watcher_main");
assert_eq!(search.source, "bash");
}
#[test]
fn extract_search_ignores_other_tools() {
let json = r#"{"session_id":"s1","cwd":"/repo","hook_event_name":"PreToolUse",
"tool_name":"Read","tool_input":{"path":"/x"}}"#;
let input: HookInput = serde_json::from_str(json).unwrap();
assert!(extract_search(&input).is_none());
}
#[allow(clippy::too_many_arguments)]
fn make_orientation(
root_title: Option<&str>,
nodes: Vec<TreeNode>,
truncated: u32,
load_bearing: Vec<(&str, u64)>,
recent: Vec<&str>,
hot: Vec<&str>,
memory_titles: Vec<&str>,
head: &str,
indexed_head: &str,
anchor: AnchorHealth,
parser_failures: u64,
) -> Orientation {
Orientation {
tree: DirTree { nodes, root_memory_title: root_title.map(str::to_string), truncated },
load_bearing: load_bearing.into_iter().map(|(p, fi)| (p.to_string(), fi)).collect(),
recent_commits: recent.into_iter().map(str::to_string).collect(),
hot_files: hot.into_iter().map(str::to_string).collect(),
active_memory_total: memory_titles.len() as u32,
active_memory_titles: memory_titles.into_iter().map(str::to_string).collect(),
head: head.to_string(),
indexed_head: indexed_head.to_string(),
anchor,
total_files: 42,
parser_failures,
}
}
fn healthy_anchor() -> AnchorHealth {
AnchorHealth { current: 3, relocated: 1, stale: 0, gone: 0 }
}
fn node(depth: u8, label: &str, path: &str, file_count: u32, title: Option<&str>) -> TreeNode {
TreeNode {
depth,
label: label.to_string(),
path: path.to_string(),
file_count,
memory_title: title.map(str::to_string),
}
}
#[test]
fn format_digest_contains_attribution_header() {
let o = make_orientation(
None,
vec![],
0,
vec![],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(s.contains("▶ rag-rat repo intelligence"), "missing attribution header");
assert!(s.contains("semantic_search"), "missing tool nudge");
}
#[test]
fn format_digest_purpose_line_when_root_title_present() {
let o = make_orientation(
Some("My project — does amazing things"),
vec![],
0,
vec![],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(s.contains("My project — does amazing things"), "missing purpose line");
}
#[test]
fn format_digest_no_purpose_line_when_root_title_absent() {
let o = make_orientation(
None,
vec![],
0,
vec![],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(!s.contains("does amazing things"));
}
#[test]
fn format_digest_layout_indents_and_annotates_tree() {
let nodes = vec![
node(0, "src", "src", 5, None),
node(1, "actors", "src/actors", 8, Some("per-domain actors")),
node(1, "data", "src/data", 3, None),
];
let o = make_orientation(
None,
nodes,
0,
vec![],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(
s.contains("LAYOUT (42 files · ‹…› = directory memory)"),
"LAYOUT header missing file count; got:\n{s}"
);
assert!(s.contains("\nsrc\n"), "depth-0 node should not be indented");
assert!(
s.contains(" actors ‹per-domain actors›"),
"depth-1 node with title missing or malformed"
);
assert!(s.contains(" data\n"), "depth-1 node without title missing");
}
#[test]
fn format_digest_truncated_note() {
let o = make_orientation(
None,
vec![node(0, "src", "src", 5, None)],
7,
vec![],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(s.contains("… (+7 more)"), "missing truncated note");
}
#[test]
fn format_digest_load_bearing_fan_in() {
let o = make_orientation(
None,
vec![],
0,
vec![
("crates/rag-rat-core/src/index/mod.rs", 2286),
("crates/rag-rat-core/src/main.rs", 42),
("src/database.rs", 999),
],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(s.contains("load-bearing:"), "missing load-bearing prefix");
assert!(s.contains("index/mod.rs (fan_in 2286)"), "crates path not shortened; got:\n{s}");
assert!(s.contains("main.rs (fan_in 42)"), "crates path not shortened for main.rs; got:\n{s}");
assert!(s.contains("src/database.rs (fan_in 999)"), "non-crates path changed; got:\n{s}");
}
#[test]
fn format_digest_memories_overflow_uses_true_total() {
let titles: Vec<&str> = vec!["alpha", "beta", "gamma"];
let mut o = make_orientation(
None,
vec![],
0,
vec![],
vec![],
vec![],
titles,
"abc",
"abc",
healthy_anchor(),
0,
);
o.active_memory_total = 9;
let s = format_digest(&o, true, true);
assert!(s.contains("alpha"), "first memory title missing");
assert!(s.contains("beta"), "second memory title missing");
assert!(s.contains("gamma"), "third memory title missing");
assert!(s.contains("(+6 more)"), "overflow note must use true total; got:\n{s}");
}
#[test]
fn format_digest_memories_no_overflow_when_three_or_fewer() {
let o = make_orientation(
None,
vec![],
0,
vec![],
vec![],
vec![],
vec!["alpha", "beta", "gamma"],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(s.contains("alpha · beta · gamma"), "three titles should be shown");
assert!(!s.contains("more)"), "no overflow note when ≤3 titles");
}
#[test]
fn short_path_strips_crates_prefix() {
assert_eq!(
short_path("crates/rag-rat-core/src/index/mod.rs"),
"index/mod.rs",
"three-segment crates prefix should be stripped"
);
assert_eq!(
short_path("crates/rag-rat-mcp/src/server.rs"),
"server.rs",
"single-file under src should be stripped"
);
}
#[test]
fn short_path_leaves_non_crates_paths_unchanged() {
assert_eq!(short_path("src/database.rs"), "src/database.rs");
assert_eq!(short_path("crates/only-two"), "crates/only-two");
assert_eq!(
short_path("crates/foo/not-src/bar.rs"),
"crates/foo/not-src/bar.rs",
"second segment is 'not-src', must not strip"
);
assert_eq!(short_path(""), "");
}
struct HealthCase {
live: bool,
enabled: bool,
head: &'static str,
indexed: &'static str,
expected: &'static str,
}
#[test]
fn format_digest_health_watcher_combinations() {
let cases = [
HealthCase {
live: true,
enabled: true,
head: "aaa",
indexed: "aaa",
expected: "index fresh (watcher live)",
},
HealthCase {
live: true,
enabled: true,
head: "aaa",
indexed: "bbb",
expected: "index syncing (watcher live)",
},
HealthCase {
live: false,
enabled: true,
head: "aaa",
indexed: "bbb",
expected: "index stale — start the rag-rat MCP server",
},
HealthCase {
live: false,
enabled: false,
head: "aaa",
indexed: "bbb",
expected: "watcher off; index stale — run 'rag-rat index'",
},
HealthCase {
live: false,
enabled: true,
head: "aaa",
indexed: "aaa",
expected: "index fresh",
},
];
for case in &cases {
let o = make_orientation(
None,
vec![],
0,
vec![],
vec![],
vec![],
vec![],
case.head,
case.indexed,
healthy_anchor(),
0,
);
let s = format_digest(&o, case.live, case.enabled);
assert!(
s.contains(case.expected),
"health line mismatch for live={} enabled={} head={}: expected {:?}, got:\n{}",
case.live,
case.enabled,
case.head,
case.expected,
s
);
}
}
#[test]
fn format_digest_gone_adds_doctor_nudge() {
let anchor = AnchorHealth { current: 2, relocated: 0, stale: 1, gone: 3 };
let o =
make_orientation(None, vec![], 0, vec![], vec![], vec![], vec![], "abc", "abc", anchor, 0);
let s = format_digest(&o, true, true);
assert!(s.contains("3 gone → run 'rag-rat memory doctor'"), "missing gone nudge");
}
#[test]
fn format_digest_no_doctor_nudge_when_gone_is_zero() {
let o = make_orientation(
None,
vec![],
0,
vec![],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
0,
);
let s = format_digest(&o, true, true);
assert!(!s.contains("memory doctor"), "unexpected doctor nudge when gone=0");
}
#[test]
fn format_digest_parser_failures_shown_when_nonzero() {
let o = make_orientation(
None,
vec![],
0,
vec![],
vec![],
vec![],
vec![],
"abc",
"abc",
healthy_anchor(),
5,
);
let s = format_digest(&o, true, true);
assert!(s.contains("parser failures: 5"), "missing parser failures note");
}
fn write_event(tool: &str, tool_input: serde_json::Value) -> HookInput {
HookInput {
hook_event_name: Some("PreToolUse".to_string()),
tool_name: tool.to_string(),
tool_input,
..Default::default()
}
}
#[test]
fn extract_clone_inputs_write_edit_multiedit() {
let root = std::path::Path::new("/repo");
let write = write_event(
"Write",
serde_json::json!({"file_path": "/repo/src/a.rs", "content": "fn f() {}"}),
);
let w = super::extract_clone_inputs(&write, root);
assert_eq!(w.len(), 1);
assert_eq!(w[0].text, "fn f() {}");
assert_eq!(w[0].path, std::path::PathBuf::from("src/a.rs"), "path relativized to root");
let edit = write_event(
"Edit",
serde_json::json!({"file_path": "/repo/src/a.rs", "new_string": "fn g() {}"}),
);
assert_eq!(super::extract_clone_inputs(&edit, root)[0].text, "fn g() {}");
let multi = write_event(
"MultiEdit",
serde_json::json!({"file_path": "/repo/src/a.rs", "edits": [{"new_string": "fn a() {}"}, {"new_string": "fn b() {}"}]}),
);
let m = super::extract_clone_inputs(&multi, root);
assert_eq!(m.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(), vec![
"fn a() {}",
"fn b() {}"
]);
}
#[test]
fn extract_clone_inputs_skips_non_code_and_missing_path() {
let root = std::path::Path::new("/repo");
let txt =
write_event("Write", serde_json::json!({"file_path": "/repo/notes.txt", "content": "hi"}));
assert!(super::extract_clone_inputs(&txt, root).is_empty());
let nofile = write_event("Write", serde_json::json!({"content": "x"}));
assert!(super::extract_clone_inputs(&nofile, root).is_empty());
}
#[test]
fn format_clone_warning_renders_matches_and_is_silent_when_empty() {
assert!(super::format_clone_warning(&[]).is_none());
let m = rag_rat_core::index::TextCloneMatch {
in_file: "src/a.rs".to_string(),
name: "foo".to_string(),
start_line: 3,
kind: "exact",
similarity: 1.0,
clone_of: vec!["src/b.rs::bar".to_string()],
};
let out = super::format_clone_warning(&[m]).unwrap();
assert!(
out.contains("foo") && out.contains("identical to") && out.contains("src/b.rs::bar"),
"{out}"
);
}
#[test]
fn format_clone_warning_caps_the_ref_list() {
let clone_of: Vec<String> = (0..12).map(|i| format!("src/m{i}.rs::f{i}")).collect();
let m = rag_rat_core::index::TextCloneMatch {
in_file: "src/a.rs".to_string(),
name: "wide".to_string(),
start_line: 1,
kind: "near",
similarity: 0.9,
clone_of,
};
let out = super::format_clone_warning(&[m]).unwrap();
assert!(out.contains("src/m0.rs::f0"), "shows the first ref: {out}");
assert!(
out.contains(&format!("(+{} more)", 12 - super::MAX_CLONE_REFS)),
"caps with a count: {out}"
);
assert!(!out.contains("src/m11.rs::f11"), "doesn't list every ref: {out}");
}
#[test]
fn clone_check_size_guard_is_fallback_only() {
assert!(super::clone_check_skipped_for_size(false, super::MAX_CLONE_CHECK_FUNCTIONS + 1));
assert!(!super::clone_check_skipped_for_size(false, super::MAX_CLONE_CHECK_FUNCTIONS));
assert!(!super::clone_check_skipped_for_size(false, 0));
assert!(!super::clone_check_skipped_for_size(true, super::MAX_CLONE_CHECK_FUNCTIONS + 1));
assert!(!super::clone_check_skipped_for_size(true, u64::MAX));
}