bobbin-ai 0.25.2

Local-first context injection engine for AI coding agents
//! Tests for `generate_hot_topics` (the SessionStart hot-topics report).
//!
//! In their own file for the reason `hook_session_id_tests.rs` states:
//! `src/cli/hook.rs` is the largest file in the tree and is capped by
//! `scripts/large-file-allowlist.txt` at the size it had when the ceiling was
//! set. Test bodies are exempt from that gate only when they live in a
//! `*tests.rs` file, so moving a self-contained block out is how the ratchet
//! is meant to be tightened — not by raising the ceiling.

use super::*;

#[test]
fn test_generate_hot_topics_empty_state() {
    let tmp = tempfile::tempdir().unwrap();
    let output_path = tmp.path().join("hot-topics.md");

    let state = HookState::default();
    generate_hot_topics(&state, &output_path).unwrap();

    let content = std::fs::read_to_string(&output_path).unwrap();
    assert!(content.contains("# Hot Topics (auto-generated by bobbin)"));
    assert!(content.contains("Based on 0 context injections."));
    assert!(content.contains("No injection data yet."));
    assert!(content.contains("## Frequently Referenced Code"));
    assert!(content.contains("## Most Referenced Files"));
}

#[test]
fn test_generate_hot_topics_with_data() {
    let tmp = tempfile::tempdir().unwrap();
    let output_path = tmp.path().join("hot-topics.md");

    let mut chunk_freqs = HashMap::new();
    chunk_freqs.insert(
        "src/cli/hook.rs:10:50".to_string(),
        ChunkFrequency {
            count: 12,
            file: "src/cli/hook.rs".to_string(),
            name: Some("InjectContextArgs".to_string()),
        },
    );
    chunk_freqs.insert(
        "src/config.rs:20:40".to_string(),
        ChunkFrequency {
            count: 9,
            file: "src/config.rs".to_string(),
            name: Some("HooksConfig".to_string()),
        },
    );
    chunk_freqs.insert(
        "src/search/context.rs:5:30".to_string(),
        ChunkFrequency {
            count: 7,
            file: "src/search/context.rs".to_string(),
            name: None,
        },
    );

    let mut file_freqs = HashMap::new();
    file_freqs.insert("src/cli/hook.rs".to_string(), 15);
    file_freqs.insert("src/config.rs".to_string(), 12);
    file_freqs.insert("src/search/context.rs".to_string(), 9);

    let state = HookState {
        last_session_id: "abc123".to_string(),
        last_injected_chunks: vec![],
        last_injection_time: "2026-02-08T10:30:00Z".to_string(),
        injection_count: 47,
        chunk_frequencies: chunk_freqs,
        file_frequencies: file_freqs,
        hot_topics_generated_at: 40,
    };

    generate_hot_topics(&state, &output_path).unwrap();

    let content = std::fs::read_to_string(&output_path).unwrap();
    assert!(content.contains("Based on 47 context injections."));
    assert!(content.contains("2026-02-08 10:30 UTC"));

    // Chunks should be ranked by count descending
    let hook_pos = content.find("InjectContextArgs").unwrap();
    let config_pos = content.find("HooksConfig").unwrap();
    assert!(
        hook_pos < config_pos,
        "Higher-count chunk should appear first"
    );

    // File table present and ranked
    assert!(content.contains("| src/cli/hook.rs | 15 |"));
    assert!(content.contains("| src/config.rs | 12 |"));

    // Symbol-less chunk shows dash
    assert!(content.contains("| - |"));
}

#[test]
fn test_generate_hot_topics_truncates_chunks_to_20() {
    let tmp = tempfile::tempdir().unwrap();
    let output_path = tmp.path().join("hot-topics.md");

    let mut chunk_freqs = HashMap::new();
    for i in 0..30 {
        chunk_freqs.insert(
            format!("src/file{}.rs:1:10", i),
            ChunkFrequency {
                count: 30 - i,
                file: format!("src/file{}.rs", i),
                name: Some(format!("fn_{}", i)),
            },
        );
    }

    let state = HookState {
        injection_count: 100,
        chunk_frequencies: chunk_freqs,
        ..Default::default()
    };

    generate_hot_topics(&state, &output_path).unwrap();

    let content = std::fs::read_to_string(&output_path).unwrap();
    // Count chunk table rows (lines starting with "| <digit>")
    let chunk_section = content
        .split("## Frequently Referenced Code")
        .nth(1)
        .unwrap()
        .split("## Most Referenced Files")
        .next()
        .unwrap();
    let rank_rows: Vec<&str> = chunk_section
        .lines()
        .filter(|l| l.starts_with("| ") && l.chars().nth(2).map_or(false, |c| c.is_ascii_digit()))
        .collect();
    assert_eq!(rank_rows.len(), 20);
}

#[test]
fn test_generate_hot_topics_truncates_files_to_10() {
    let tmp = tempfile::tempdir().unwrap();
    let output_path = tmp.path().join("hot-topics.md");

    let mut file_freqs = HashMap::new();
    for i in 0..15 {
        file_freqs.insert(format!("src/file{}.rs", i), 15 - i as u64);
    }

    let state = HookState {
        injection_count: 50,
        file_frequencies: file_freqs,
        ..Default::default()
    };

    generate_hot_topics(&state, &output_path).unwrap();

    let content = std::fs::read_to_string(&output_path).unwrap();
    // Count table rows in the file section
    let file_section = content.split("## Most Referenced Files").nth(1).unwrap();
    let row_count = file_section
        .lines()
        .filter(|l| l.starts_with("| src/"))
        .count();
    assert_eq!(row_count, 10);
}