use super::{
CROSS_PROJECT_PENALTY, HOP_PENALTY, select_lifecycle_candidates, select_scored_notes,
superseded_record_ids,
};
use crate::domain::{
MemoryLifecycleState, MemoryOrigin, MemoryRecord, MemoryScope, MemorySourceKind, Note,
OutputFormat, RouteInput, Section, TargetTool,
};
use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
fn make_input() -> RouteInput {
RouteInput {
task: "test".to_string(),
cwd: PathBuf::from("/tmp/repo"),
files: Vec::new(),
target: TargetTool::Codex,
format: OutputFormat::Prompt,
}
}
fn make_record(title: &str, memory_type: &str) -> MemoryRecord {
MemoryRecord {
title: title.to_string(),
summary: "body".to_string(),
memory_type: memory_type.to_string(),
scope: MemoryScope::User,
state: MemoryLifecycleState::Accepted,
origin: MemoryOrigin {
source_kind: MemorySourceKind::Manual,
source_ref: "test".to_string(),
},
project_id: None,
user_id: None,
sensitivity: None,
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
}
}
fn make_record_with_relations(
title: &str,
memory_type: &str,
related: Vec<String>,
) -> MemoryRecord {
let mut record = make_record(title, memory_type);
record.related_records = related;
record
}
fn make_note(relative_path: &str, title: &str, content: &str, wikilinks: &[&str]) -> Note {
Note::new(
PathBuf::from(format!("/tmp/vault/{relative_path}")),
relative_path.to_string(),
title.to_string(),
BTreeMap::new(),
vec![Section {
heading: None,
level: 0,
content: content.to_string(),
}],
wikilinks.iter().map(|v| v.to_string()).collect(),
content.to_string(),
)
}
#[test]
fn lifecycle_selector_should_truncate_to_limit() {
let input = make_input();
let records = vec![
("r1".to_string(), make_record("a", "constraint")),
("r2".to_string(), make_record("b", "decision")),
("r3".to_string(), make_record("c", "preference")),
("r4".to_string(), make_record("d", "incident")),
];
let selected = select_lifecycle_candidates(None, &records, &input, 2, &HashSet::new(), None);
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].memory_type, "constraint");
assert_eq!(selected[1].memory_type, "decision");
}
#[test]
fn lifecycle_selector_should_break_ties_by_record_id() {
let input = make_input();
let records = vec![
("r-beta".to_string(), make_record("b", "decision")),
("r-alpha".to_string(), make_record("a", "decision")),
];
let selected = select_lifecycle_candidates(None, &records, &input, 10, &HashSet::new(), None);
assert_eq!(selected[0].record_id, "r-alpha");
assert_eq!(selected[1].record_id, "r-beta");
}
#[test]
fn lifecycle_selector_with_zero_limit_returns_empty() {
let input = make_input();
let records = vec![("r1".to_string(), make_record("a", "constraint"))];
assert!(
select_lifecycle_candidates(None, &records, &input, 0, &HashSet::new(), None).is_empty()
);
}
#[test]
fn lifecycle_selector_should_exclude_record_ids_already_covered_by_notes() {
let input = make_input();
let records = vec![
("r-keep".to_string(), make_record("keep", "constraint")),
("r-drop".to_string(), make_record("drop", "decision")),
];
let mut excluded = HashSet::new();
excluded.insert("r-drop".to_string());
let selected = select_lifecycle_candidates(None, &records, &input, 10, &excluded, None);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].record_id, "r-keep");
}
#[test]
fn lifecycle_relation_expansion_should_pull_in_related_records() {
use crate::domain::MatchedProject;
let input = make_input();
let project = MatchedProject {
id: "spool".to_string(),
name: "spool".to_string(),
reason: "test".to_string(),
};
let mut r3_record = make_record("related pattern", "pattern");
r3_record.scope = MemoryScope::Project;
r3_record.project_id = Some("other-project".to_string());
let records = vec![
(
"r1".to_string(),
make_record_with_relations("top constraint", "constraint", vec!["r3".to_string()]),
),
("r2".to_string(), make_record("mid decision", "decision")),
("r3".to_string(), r3_record),
];
let selected =
select_lifecycle_candidates(Some(&project), &records, &input, 5, &HashSet::new(), None);
let r3 = selected.iter().find(|c| c.record_id == "r3");
assert!(
r3.is_some(),
"r3 should be pulled in via relation expansion from r1. Got: {:?}",
selected
.iter()
.map(|c| (&c.record_id, c.score))
.collect::<Vec<_>>()
);
let r3 = r3.unwrap();
assert!(
r3.reasons.iter().any(|r| r.contains("relation-expanded")),
"r3 should have relation-expanded reason, got: {:?}",
r3.reasons
);
assert!(r3.score > 0);
}
#[test]
fn lifecycle_relation_expansion_should_apply_hop_penalty() {
use crate::domain::MatchedProject;
let input = make_input();
let project = MatchedProject {
id: "spool".to_string(),
name: "spool".to_string(),
reason: "test".to_string(),
};
let mut r1 = make_record_with_relations("top constraint", "constraint", vec!["r2".to_string()]);
r1.scope = MemoryScope::Project;
r1.project_id = Some("spool".to_string());
let mut r2 = make_record("related workflow", "workflow");
r2.scope = MemoryScope::Project;
r2.project_id = Some("other".to_string());
let records = vec![("r1".to_string(), r1), ("r2".to_string(), r2)];
let selected =
select_lifecycle_candidates(Some(&project), &records, &input, 5, &HashSet::new(), None);
let r1_candidate = selected.iter().find(|c| c.record_id == "r1").unwrap();
let r2_candidate = selected.iter().find(|c| c.record_id == "r2");
assert!(
r2_candidate.is_some(),
"r2 should be pulled in via expansion"
);
let r2_candidate = r2_candidate.unwrap();
let expected_r2_score = ((r1_candidate.score as f64) * HOP_PENALTY) as i32;
assert_eq!(
r2_candidate.score, expected_r2_score,
"expanded score should be referrer_score * HOP_PENALTY: {} * {} = {}, got {}",
r1_candidate.score, HOP_PENALTY, expected_r2_score, r2_candidate.score
);
assert!(r2_candidate.score < r1_candidate.score);
}
#[test]
fn lifecycle_relation_expansion_should_not_duplicate_already_selected() {
let input = make_input();
let records = vec![
(
"r1".to_string(),
make_record_with_relations("constraint A", "constraint", vec!["r2".to_string()]),
),
(
"r2".to_string(),
make_record_with_relations("constraint B", "constraint", vec!["r1".to_string()]),
),
];
let selected = select_lifecycle_candidates(None, &records, &input, 10, &HashSet::new(), None);
assert_eq!(selected.len(), 2);
let ids: HashSet<&str> = selected.iter().map(|c| c.record_id.as_str()).collect();
assert_eq!(ids.len(), 2);
}
#[test]
fn note_relation_expansion_should_pull_in_wikilinked_notes() {
let input = RouteInput {
task: "routing design".to_string(),
cwd: PathBuf::from("/tmp/repo"),
files: vec!["src/engine/project_matcher.rs".to_string()],
target: TargetTool::Codex,
format: OutputFormat::Prompt,
};
let notes = vec![
make_note(
"10-Projects/routing.md",
"Routing Design",
"The routing module handles project matching.",
&["Architecture Overview"],
),
make_note(
"10-Projects/architecture.md",
"Architecture Overview",
"High-level system architecture.",
&[],
),
make_note(
"10-Projects/unrelated.md",
"Unrelated Note",
"Something completely different about cooking.",
&[],
),
];
let selected = select_scored_notes(None, None, &[], &[], ¬es, &input, 2);
assert!(
selected
.iter()
.any(|s| s.note.relative_path == "10-Projects/routing.md"),
"routing note should be selected"
);
assert!(
!selected
.iter()
.any(|s| s.note.relative_path == "10-Projects/unrelated.md"),
"unrelated note should not be pulled in"
);
}
#[test]
fn superseded_record_ids_should_collect_knowledge_related_and_explicit_supersedes() {
let mut wiki = make_record("Auth Wiki", "knowledge");
wiki.state = MemoryLifecycleState::Accepted;
wiki.related_records = vec!["frag-a".to_string(), "frag-b".to_string()];
let mut replacement = make_record("New Decision", "decision");
replacement.state = MemoryLifecycleState::Canonical;
replacement.supersedes = Some("old-decision".to_string());
let mut pending_wiki = make_record("Pending Wiki", "knowledge");
pending_wiki.state = MemoryLifecycleState::Candidate;
pending_wiki.related_records = vec!["frag-c".to_string()];
let records = vec![
("wiki-1".to_string(), wiki),
("rep-1".to_string(), replacement),
("wiki-pending".to_string(), pending_wiki),
];
let superseded = superseded_record_ids(&records);
assert!(superseded.contains("frag-a"));
assert!(superseded.contains("frag-b"));
assert!(superseded.contains("old-decision"));
assert!(
!superseded.contains("frag-c"),
"pending wiki should not supersede until accepted"
);
}
#[test]
fn lifecycle_selector_should_apply_cross_project_penalty_when_project_matched() {
use crate::domain::MatchedProject;
let input = make_input();
let project = MatchedProject {
id: "spool".to_string(),
name: "spool".to_string(),
reason: "test".to_string(),
};
let mut project_record = make_record("project pattern", "pattern");
project_record.scope = MemoryScope::Project;
project_record.project_id = Some("spool".to_string());
project_record.entities = vec!["rust".to_string()];
let mut user_record = make_record("user pattern", "pattern");
user_record.scope = MemoryScope::User;
user_record.entities = vec!["rust".to_string()];
let records = vec![
("proj-1".to_string(), project_record),
("user-1".to_string(), user_record),
];
let selected =
select_lifecycle_candidates(Some(&project), &records, &input, 10, &HashSet::new(), None);
let proj = selected.iter().find(|c| c.record_id == "proj-1");
let user = selected.iter().find(|c| c.record_id == "user-1");
assert!(proj.is_some(), "project-scoped record must be selected");
assert!(user.is_some(), "user-scoped record must still be selected");
let proj_score = proj.unwrap().score;
let user_score = user.unwrap().score;
assert!(
proj_score > user_score,
"project-scoped record ({proj_score}) must outscore cross-project record ({user_score})"
);
let user_candidate = user.unwrap();
assert!(
user_candidate
.reasons
.iter()
.any(|r| r.contains("cross-project penalty")),
"user-scoped candidate must carry cross-project penalty reason: {:?}",
user_candidate.reasons
);
}
#[test]
fn lifecycle_selector_should_not_apply_cross_project_penalty_without_project() {
let input = make_input();
let mut user_record = make_record("user pattern", "pattern");
user_record.scope = MemoryScope::User;
user_record.entities = vec!["rust".to_string()];
let records = vec![("user-1".to_string(), user_record)];
let selected = select_lifecycle_candidates(None, &records, &input, 10, &HashSet::new(), None);
let user = selected.iter().find(|c| c.record_id == "user-1");
if let Some(user) = user {
assert!(
!user
.reasons
.iter()
.any(|r| r.contains("cross-project penalty")),
"no penalty should be applied without project: {:?}",
user.reasons
);
}
let _ = CROSS_PROJECT_PENALTY; }