aicx 0.10.0

Context search and retrieval for AI-agent session history — find relevant past events, recover user intent, identify unfinished deliverables, and spot claims never backed by results
Documentation
use super::*;

#[test]
fn test_conversation_json_extract_stats_are_additive() {
    let dir = unique_test_dir("conversation_extract_stats");
    let path = dir.join("conversation.json");
    let messages = vec![ConversationMessage {
        timestamp: Utc.with_ymd_and_hms(2026, 1, 23, 12, 0, 0).unwrap(),
        agent: "claude".to_string(),
        session_id: "sess-stats".to_string(),
        role: "user".to_string(),
        message: "Hello".to_string(),
        repo_project: "test".to_string(),
        source_path: None,
        branch: None,
        message_kind: crate::timeline::MessageKind::Conversation,
        collapse_stub_kind: None,
    }];
    let metadata = ReportMetadata {
        generated_at: Utc.with_ymd_and_hms(2026, 1, 23, 13, 0, 0).unwrap(),
        project_filter: Some("test".to_string()),
        hours_back: 24,
        total_entries: 2,
        sessions: vec!["sess-stats".to_string()],
    };
    let stats = ConversationExtractStats {
        aicx_version: env!("CARGO_PKG_VERSION"),
        redaction_enabled: true,
        raw_entries: 2,
        conversation_messages: messages.len(),
        conversation_projection: "user_assistant_only",
        exact_short_duplicates_dropped: 1,
        harness_noise_dropped: 0,
    };

    write_conversation_json(&path, &messages, &metadata, &stats).unwrap();

    let content = fs::read_to_string(&path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
    assert_eq!(
        parsed["extract_stats"]["aicx_version"],
        env!("CARGO_PKG_VERSION")
    );
    assert_eq!(parsed["extract_stats"]["redaction_enabled"], true);
    assert_eq!(parsed["extract_stats"]["raw_entries"], 2);
    assert_eq!(parsed["extract_stats"]["conversation_messages"], 1);
    assert_eq!(
        parsed["extract_stats"]["conversation_projection"],
        "user_assistant_only"
    );
    assert_eq!(parsed["extract_stats"]["exact_short_duplicates_dropped"], 1);
    assert_eq!(
        parsed["extract_stats"]["conversation_messages"],
        parsed["messages"].as_array().unwrap().len()
    );
    assert!(
        parsed["extract_stats"]["raw_entries"].as_u64().unwrap()
            >= parsed["extract_stats"]["conversation_messages"]
                .as_u64()
                .unwrap()
    );

    cleanup(&dir);
}

#[test]
fn write_conversation_markdown_emits_date_heading_for_recency() {
    // P0 cognitive: extracts carried time-only stamps; a reader could not tell a
    // session from yesterday apart from one 8 months old. The markdown must carry
    // the full date (with year) once per session day, including day boundaries.
    let dir = unique_test_dir("conversation_md_date_heading");
    let path = dir.join("conversation.md");
    let mk = |y, mo, d, h, role: &str, body: &str| ConversationMessage {
        timestamp: Utc.with_ymd_and_hms(y, mo, d, h, 0, 0).unwrap(),
        agent: "claude".to_string(),
        session_id: "sess-date".to_string(),
        role: role.to_string(),
        message: body.to_string(),
        repo_project: "test".to_string(),
        source_path: None,
        branch: None,
        message_kind: crate::timeline::MessageKind::Conversation,
        collapse_stub_kind: None,
    };
    let messages = vec![
        mk(2026, 1, 23, 9, "user", "first day"),
        mk(2026, 1, 24, 10, "assistant", "next day"),
    ];
    let metadata = ReportMetadata {
        generated_at: Utc.with_ymd_and_hms(2026, 1, 24, 13, 0, 0).unwrap(),
        project_filter: Some("test".to_string()),
        hours_back: 48,
        total_entries: 2,
        sessions: vec!["sess-date".to_string()],
    };

    write_conversation_markdown(&path, &messages, &metadata).unwrap();

    let content = fs::read_to_string(&path).unwrap();
    assert!(
        content.contains("#### 2026-01-23 (UTC)"),
        "missing first-day date heading (with year and UTC marker):\n{content}"
    );
    assert!(
        content.contains("#### 2026-01-24 (UTC)"),
        "day boundary must emit a second date heading (with UTC marker):\n{content}"
    );

    cleanup(&dir);
}

#[test]
fn write_conversation_markdown_handles_non_ascii_session_ids() {
    let dir = unique_test_dir("conversation_md_non_ascii_session_id");
    let path = dir.join("conversation.md");
    let messages = vec![ConversationMessage {
        timestamp: Utc.with_ymd_and_hms(2026, 1, 24, 13, 0, 0).unwrap(),
        agent: "claude".to_string(),
        session_id: "abcdef😀tail".to_string(),
        role: "user".to_string(),
        message: "non-ascii session id should not panic".to_string(),
        repo_project: "test".to_string(),
        source_path: None,
        branch: None,
        message_kind: crate::timeline::MessageKind::Conversation,
        collapse_stub_kind: None,
    }];
    let metadata = ReportMetadata {
        generated_at: Utc.with_ymd_and_hms(2026, 1, 24, 13, 0, 0).unwrap(),
        project_filter: Some("test".to_string()),
        hours_back: 24,
        total_entries: 1,
        sessions: vec!["abcdef😀tail".to_string()],
    };

    write_conversation_markdown(&path, &messages, &metadata).unwrap();
    let content = fs::read_to_string(&path).unwrap();

    assert!(
        content.contains("### Session `abcdef😀t` [claude]"),
        "session heading should truncate by characters, not bytes:\n{content}"
    );

    cleanup(&dir);
}

#[test]
fn test_conversation_json_extract_stats_can_report_redaction_disabled() {
    let dir = unique_test_dir("conversation_extract_stats_redaction_disabled");
    let path = dir.join("conversation.json");
    let messages = vec![ConversationMessage {
        timestamp: Utc.with_ymd_and_hms(2026, 1, 23, 12, 0, 0).unwrap(),
        agent: "claude".to_string(),
        session_id: "sess-stats".to_string(),
        role: "user".to_string(),
        message: "Hello".to_string(),
        repo_project: "test".to_string(),
        source_path: None,
        branch: None,
        message_kind: crate::timeline::MessageKind::Conversation,
        collapse_stub_kind: None,
    }];
    let metadata = ReportMetadata {
        generated_at: Utc.with_ymd_and_hms(2026, 1, 23, 13, 0, 0).unwrap(),
        project_filter: Some("test".to_string()),
        hours_back: 24,
        total_entries: 1,
        sessions: vec!["sess-stats".to_string()],
    };
    let stats = ConversationExtractStats {
        aicx_version: env!("CARGO_PKG_VERSION"),
        redaction_enabled: false,
        raw_entries: 1,
        conversation_messages: messages.len(),
        conversation_projection: "user_assistant_only",
        exact_short_duplicates_dropped: 0,
        harness_noise_dropped: 0,
    };

    write_conversation_json(&path, &messages, &metadata, &stats).unwrap();

    let content = fs::read_to_string(&path).unwrap();
    let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
    assert_eq!(parsed["extract_stats"]["redaction_enabled"], false);

    cleanup(&dir);
}

#[test]
fn write_conversation_outputs_redact_by_default() {
    let dir = unique_test_dir("conversation_redact_default");
    let md_path = dir.join("conversation.md");
    let json_path = dir.join("conversation.json");
    let raw = "raw token sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWX should not leak";
    let messages = vec![conversation_message(raw)];
    let metadata = conversation_metadata(messages.len());
    let stats = conversation_stats(true, messages.len());

    write_conversation_markdown(&md_path, &messages, &metadata).unwrap();
    write_conversation_json(&json_path, &messages, &metadata, &stats).unwrap();

    let md = fs::read_to_string(&md_path).unwrap();
    let json = fs::read_to_string(&json_path).unwrap();
    assert!(!md.contains("sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWX"));
    assert!(!json.contains("sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWX"));
    assert!(md.contains("[REDACTED_ANTHROPIC_KEY]"));
    assert!(json.contains("[REDACTED_ANTHROPIC_KEY]"));

    cleanup(&dir);
}

#[test]
fn write_conversation_outputs_can_preserve_raw_with_explicit_flag() {
    let dir = unique_test_dir("conversation_redact_opt_out");
    let md_path = dir.join("conversation.md");
    let json_path = dir.join("conversation.json");
    let raw = "raw token sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWX preserved";
    let messages = vec![conversation_message(raw)];
    let metadata = conversation_metadata(messages.len());
    let stats = conversation_stats(false, messages.len());

    write_conversation_markdown_with_redaction(&md_path, &messages, &metadata, false).unwrap();
    write_conversation_json_with_redaction(&json_path, &messages, &metadata, &stats, false)
        .unwrap();

    let md = fs::read_to_string(&md_path).unwrap();
    let json = fs::read_to_string(&json_path).unwrap();
    assert!(md.contains("sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWX"));
    assert!(json.contains("sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWX"));

    cleanup(&dir);
}

// --- Loctree ---