remem-ai 0.4.4

Persistent memory for Claude Code — single binary, zero subprocesses
Documentation
use crate::workstream::{WorkStream, WorkStreamStatus};

use super::super::policy::ContextLimits;
use super::super::render::{build_context_stats_footer, ContextRenderStats, SectionRenderStats};
use super::super::sections::{
    render_core_memory, render_core_memory_with_limits, render_memory_index,
    render_memory_index_with_limits, render_memory_index_with_limits_excluding,
    render_recent_sessions, render_recent_sessions_with_limit, render_workstreams,
    render_workstreams_with_limits,
};
use super::super::types::SessionSummaryBrief;
use super::{sample_memory, sample_memory_with_epoch, sample_workstream};

#[test]
fn render_recent_sessions_truncates_completed_line() {
    let mut output = String::new();
    let summaries = vec![SessionSummaryBrief {
        request: "Implement feature".to_string(),
        completed: Some(format!("{}\nignored", "x".repeat(130))),
        created_at_epoch: 1_710_000_000,
    }];

    render_recent_sessions(&mut output, &summaries);

    assert!(output.contains("Implement feature"));
    assert!(output.contains("=> "));
    assert!(output.contains("..."));
    assert!(!output.contains("ignored"));
}

#[test]
fn render_recent_sessions_truncates_request_text() {
    let mut output = String::new();
    let long_request = format!("Investigate SessionStart budget {}", "x".repeat(300));
    let summaries = vec![SessionSummaryBrief {
        request: long_request.clone(),
        completed: Some("done".to_string()),
        created_at_epoch: 1_710_000_000,
    }];

    render_recent_sessions(&mut output, &summaries);

    assert!(output.contains("Investigate SessionStart budget"));
    assert!(output.contains("..."));
    assert!(!output.contains(&long_request));
}

#[test]
fn render_recent_sessions_respects_char_limit() {
    let mut output = String::new();
    let summaries = vec![
        SessionSummaryBrief {
            request: "Short followup".to_string(),
            completed: Some("done".to_string()),
            created_at_epoch: 1_710_000_000,
        },
        SessionSummaryBrief {
            request: "Second session should not fit".to_string(),
            completed: Some("done".to_string()),
            created_at_epoch: 1_710_000_100,
        },
    ];

    render_recent_sessions_with_limit(&mut output, &summaries, 70);

    assert!(output.contains("Short followup"));
    assert!(!output.contains("Second session should not fit"));
    assert!(output.chars().count() <= 70);
}

#[test]
fn render_memory_index_prioritizes_known_types() {
    let mut output = String::new();
    let memories = vec![
        sample_memory(1, "custom", "Custom title"),
        sample_memory(2, "bugfix", "Fix title"),
        sample_memory(3, "decision", "Decision title"),
    ];

    render_memory_index(&mut output, &memories);

    let decision_pos = output.find("**Decisions**").unwrap();
    let bugfix_pos = output.find("**Bug Fixes**").unwrap();
    let custom_pos = output.find("**custom**").unwrap();
    assert!(decision_pos < bugfix_pos);
    assert!(bugfix_pos < custom_pos);
}

#[test]
fn render_memory_index_excludes_preferences() {
    let mut output = String::new();
    let memories = vec![
        sample_memory(1, "preference", "Preference title"),
        sample_memory(2, "decision", "Decision title"),
    ];

    render_memory_index(&mut output, &memories);

    assert!(output.contains("Decision title"));
    assert!(!output.contains("Preference title"));
    assert!(!output.contains("**Preferences**"));
}

#[test]
fn render_memory_index_respects_item_limit() {
    let mut output = String::new();
    let limits = ContextLimits {
        memory_index_limit: 2,
        ..ContextLimits::default()
    };
    let memories = vec![
        sample_memory(1, "decision", "Decision one"),
        sample_memory(2, "decision", "Decision two"),
        sample_memory(3, "decision", "Decision three"),
    ];

    render_memory_index_with_limits(&mut output, &memories, &limits);

    assert!(output.contains("Decision one"));
    assert!(output.contains("Decision two"));
    assert!(!output.contains("Decision three"));
}

#[test]
fn render_memory_index_truncates_first_item_to_char_limit() {
    let mut output = String::new();
    let limits = ContextLimits {
        memory_index_char_limit: 48,
        ..ContextLimits::default()
    };
    let long_title = "Decision title that is far too long for the index budget";
    let memories = vec![sample_memory(1, "decision", long_title)];

    let rendered = render_memory_index_with_limits(&mut output, &memories, &limits);
    let body = output.strip_prefix("## Index\n").unwrap().trim_end();

    assert_eq!(rendered, 1);
    assert!(body.chars().count() <= limits.memory_index_char_limit);
    assert!(output.contains("..."));
    assert!(!output.contains(long_title));
}

#[test]
fn render_memory_index_can_skip_core_selected_ids() {
    let mut core_output = String::new();
    let now = chrono::Utc::now().timestamp();
    let memories = vec![
        sample_memory_with_epoch(1, "bugfix", "Core bugfix", now),
        sample_memory_with_epoch(2, "decision", "Index decision", now),
    ];
    let core_summary = render_core_memory_with_limits(
        &mut core_output,
        &memories,
        &ContextLimits {
            core_item_limit: 1,
            ..ContextLimits::default()
        },
    );
    let excluded_ids = core_summary.ids.into_iter().collect();

    let mut index_output = String::new();
    let rendered = render_memory_index_with_limits_excluding(
        &mut index_output,
        &memories,
        &ContextLimits::default(),
        &excluded_ids,
    );

    assert_eq!(rendered, 1);
    assert!(!index_output.contains("Core bugfix"));
    assert!(index_output.contains("Index decision"));
}

#[test]
fn render_workstreams_includes_next_action_when_present() {
    let mut output = String::new();
    let workstreams = vec![WorkStream {
        id: 7,
        project: "demo/project".to_string(),
        title: "Refactor context".to_string(),
        description: None,
        status: WorkStreamStatus::Active,
        progress: None,
        next_action: Some("split renderers".to_string()),
        blockers: None,
        created_at_epoch: 0,
        updated_at_epoch: 0,
        completed_at_epoch: None,
    }];

    render_workstreams(&mut output, &workstreams);

    assert!(output.contains("#7 [active] Refactor context -> split renderers"));
}

#[test]
fn render_workstreams_respects_item_and_char_limits() {
    let mut output = String::new();
    let workstreams = vec![
        sample_workstream(1, "First stream", Some("ship the first fix")),
        sample_workstream(2, "Second stream", Some("ship the second fix")),
        sample_workstream(3, "Third stream", Some("ship the third fix")),
    ];

    render_workstreams_with_limits(&mut output, &workstreams, 2, 200);

    assert!(output.contains("#1 [active] First stream"));
    assert!(output.contains("#2 [active] Second stream"));
    assert!(!output.contains("#3 [active] Third stream"));
    assert!(output.chars().count() <= 200);
}

#[test]
fn render_workstreams_stops_at_char_limit() {
    let mut output = String::new();
    let workstreams = vec![
        sample_workstream(1, "First", Some("fix")),
        sample_workstream(2, "Second", Some("fix")),
    ];

    render_workstreams_with_limits(&mut output, &workstreams, 10, 48);

    assert!(output.contains("#1 [active] First"));
    assert!(!output.contains("#2 [active] Second"));
    assert!(output.chars().count() <= 48);
}

#[test]
fn render_core_memory_prioritizes_higher_score_memories() {
    let mut output = String::new();
    let now = chrono::Utc::now().timestamp();
    let memories = vec![
        sample_memory_with_epoch(1, "discovery", "Lower score", now),
        sample_memory_with_epoch(2, "decision", "Higher score", now),
    ];

    render_core_memory(&mut output, &memories);

    let high_pos = output.find("**#2 Higher score**").unwrap();
    let low_pos = output.find("**#1 Lower score**").unwrap();
    assert!(high_pos < low_pos);
}

#[test]
fn render_core_memory_truncates_first_item_to_char_limit() {
    let mut output = String::new();
    let limits = ContextLimits {
        core_char_limit: 120,
        ..ContextLimits::default()
    };
    let mut long_memory = sample_memory(1, "decision", "Compact title");
    long_memory.text = "x".repeat(500);
    let memories = vec![long_memory];

    render_core_memory_with_limits(&mut output, &memories, &limits);

    let body = output.strip_prefix("## Core\n").unwrap().trim_end();
    assert!(output.chars().count() <= limits.core_char_limit);
    assert!(body.chars().count() <= limits.core_char_limit);
    assert!(output.contains("..."));
}

#[test]
fn render_core_memory_keeps_type_diversity_before_filling_same_type() {
    let mut output = String::new();
    let now = chrono::Utc::now().timestamp();
    let memories = vec![
        sample_memory_with_epoch(1, "decision", "Decision one", now),
        sample_memory_with_epoch(2, "decision", "Decision two", now),
        sample_memory_with_epoch(3, "decision", "Decision three", now),
        sample_memory_with_epoch(4, "discovery", "Operational discovery", now),
    ];

    render_core_memory(&mut output, &memories);

    let discovery_pos = output.find("**#4 Operational discovery**").unwrap();
    let third_decision_pos = output.find("**#3 Decision three**").unwrap();
    assert!(discovery_pos < third_decision_pos);
}

#[test]
fn render_core_memory_does_not_backfill_with_memory_self_diagnostics() {
    let mut output = String::new();
    let now = chrono::Utc::now().timestamp();
    let memories = vec![
        sample_memory_with_epoch(1, "decision", "Memory injection diagnosis", now),
        sample_memory_with_epoch(2, "discovery", "Runtime hook finding", now),
    ];

    render_core_memory(&mut output, &memories);

    let runtime_pos = output.find("**#2 Runtime hook finding**").unwrap();
    assert!(runtime_pos < output.len());
    assert!(!output.contains("Memory injection diagnosis"));
}

#[test]
fn render_core_memory_keeps_stale_decision_out_when_recent_context_is_available() {
    let mut output = String::new();
    let now = chrono::Utc::now().timestamp();
    let stale_epoch = now - 8 * 86400;
    let memories = vec![
        sample_memory_with_epoch(1, "decision", "Recent decision one", now),
        sample_memory_with_epoch(3, "discovery", "Recent discovery one", now),
        sample_memory_with_epoch(4, "discovery", "Recent discovery two", now),
        sample_memory_with_epoch(5, "preference", "Recent preference one", now),
        sample_memory_with_epoch(6, "preference", "Recent preference two", now),
        sample_memory_with_epoch(7, "decision", "Stale landing page decision", stale_epoch),
    ];

    render_core_memory(&mut output, &memories);

    assert!(!output.contains("Stale landing page decision"));
}

#[test]
fn context_stats_footer_reports_budget_scope_and_truncation() {
    let footer = build_context_stats_footer(&ContextRenderStats {
        host: "codex-cli".to_string(),
        branch: Some("fix/context".to_string()),
        total_char_limit: 12_000,
        memories_loaded: 7,
        core: SectionRenderStats {
            count: 2,
            chars: 430,
        },
        index: SectionRenderStats {
            count: 5,
            chars: 800,
        },
        preferences: SectionRenderStats {
            count: 3,
            chars: 240,
        },
        project_preferences: 2,
        global_preferences: 1,
        sessions: SectionRenderStats {
            count: 4,
            chars: 620,
        },
        workstreams: SectionRenderStats {
            count: 1,
            chars: 80,
        },
        core_ids: vec![1, 2],
        output_chars: 3_200,
        truncated: true,
    });

    assert!(footer.contains("7 context memories loaded"));
    assert!(footer.contains("2 core (430 chars)"));
    assert!(footer.contains("3 preferences (project:2 global:1"));
    assert!(footer.contains("host=codex-cli"));
    assert!(footer.contains("branch=fix/context"));
    assert!(footer.contains("total=3200 chars/~800 tokens"));
    assert!(footer.contains("truncated=yes"));
}