spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
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, &[], &[], &notes, &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(),
    };

    // Project-scoped record for the matched project.
    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()];

    // User-scoped (cross-project) record with same entities — would normally
    // score similarly but should be penalized when a project is matched.
    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() {
    // When no project is matched (global retrieval), user-scoped records
    // must NOT receive the cross-project penalty.
    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
        );
    }
    // (record may score 0 and not appear — that's fine, the test just checks no penalty)
    let _ = CROSS_PROJECT_PENALTY; // ensure constant is used
}