csift 0.8.2

ripgrep for Claude Code session transcripts: fast regex list/search over ~/.claude/projects/**/*.jsonl
use super::*;
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};

#[test]
fn truncate_short_excerpt_unchanged() {
    assert_eq!(truncate_excerpt("hello"), "hello");
}

#[test]
fn truncate_long_excerpt_marks_dropped_count() {
    let s = "x".repeat(EXCERPT_MAX + 5);
    let out = truncate_excerpt(&s);
    assert!(out.ends_with("… (+5 chars)"), "got: {out}");
    assert!(out.starts_with(&"x".repeat(EXCERPT_MAX)));
}

#[test]
fn truncate_counts_chars_not_bytes() {
    // Multi-byte UTF-8 chars (this emoji is 4 bytes); truncation must count
    // chars, not bytes, for ANY script.
    let s = "🛠".repeat(EXCERPT_MAX + 2);
    let out = truncate_excerpt(&s);
    assert!(out.ends_with("… (+2 chars)"), "got: {out}");
}

#[test]
fn format_timestamp_uses_system_local_canonical_marker() {
    // tz-agnostic: the local portion must equal what the system tz itself yields
    // for this instant (derived in-test, not a hardcoded zone). v0.5: the marker
    // is `<TZAB>(UTC±offset)` and the raw-UTC copy is GONE (it invited LLM
    // conversion arithmetic; machine consumers read JSON ts_utc).
    let raw = "2026-06-07T05:48:22.880Z";
    let out = format_timestamp(Some(raw));
    let ts: jiff::Timestamp = raw.parse().expect("parseable instant");
    let local = ts
        .to_zoned(crate::timez::local_tz())
        .strftime("%Y-%m-%d %H:%M:%S")
        .to_string();
    assert!(out.contains(&local), "expected local {local:?} in {out:?}");
    assert!(out.contains("(UTC"), "marker missing: {out}");
    assert!(!out.contains(raw), "the raw-UTC copy must be gone: {out}");
}

#[test]
fn format_timestamp_missing_is_em_dash() {
    assert_eq!(format_timestamp(None), "");
}

#[test]
fn format_timestamp_unparseable_surfaces_raw() {
    let out = format_timestamp(Some("not-a-time"));
    assert!(out.contains("not-a-time"));
    assert!(out.contains("unparsed"));
}

#[test]
fn local_iso_matches_system_tz_offset() {
    let raw = "2026-06-07T05:48:22.880Z";
    let out = local_iso(raw).expect("local iso");
    // Derive the expected offset string from jiff (tz-agnostic), not a literal.
    let ts: jiff::Timestamp = raw.parse().expect("parseable instant");
    let expected = ts
        .to_zoned(crate::timez::local_tz())
        .strftime("%Y-%m-%dT%H:%M:%S%:z")
        .to_string();
    assert_eq!(out, expected);
}

static COUNTER: AtomicU64 = AtomicU64::new(0);

fn tmp_session(name_stem: &str, lines: &[&str]) -> PathBuf {
    let p = std::env::temp_dir().join(format!(
        "csift-sess-{}-{}-{name_stem}.jsonl",
        std::process::id(),
        COUNTER.fetch_add(1, Ordering::Relaxed)
    ));
    let mut f = std::fs::File::create(&p).unwrap();
    for l in lines {
        writeln!(f, "{l}").unwrap();
    }
    p
}

#[test]
fn capture_identity_if_empty_only_fills_blanks() {
    let rec: Record = serde_json::from_str(
            r#"{"type":"user","cwd":"/c","version":"9.9","gitBranch":"b","sessionId":"sid","message":{"role":"user","content":"x"}}"#,
        )
        .unwrap();
    let mut cwd = None;
    let mut version = Some("keep".to_string()); // already set → must NOT be overwritten
    let mut branch = None;
    let mut sid = None;
    capture_identity_if_empty(&rec, &mut cwd, &mut version, &mut branch, &mut sid);
    assert_eq!(cwd.as_deref(), Some("/c"));
    assert_eq!(version.as_deref(), Some("keep"), "pre-set value preserved");
    assert_eq!(branch.as_deref(), Some("b"));
    assert_eq!(sid.as_deref(), Some("sid"));
}

#[test]
fn summarize_head_first_user_captures_identity() {
    // The head finds a genuine user FIRST → identity captured from it; the tail
    // finds the last user + last agent. Exercises the head identity-capture arm
    // and both tail `is_none()` branches.
    let p = tmp_session(
        "head",
        &[
            r#"{"type":"user","cwd":"/Users/testuser/Projects/foo","version":"2.1.0","gitBranch":"main","sessionId":"sid-data","message":{"role":"user","content":"first q"}}"#,
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"mid agent"}]}}"#,
            r#"{"type":"user","message":{"role":"user","content":"last q"}}"#,
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"last agent"}]}}"#,
        ],
    );
    let s = summarize_session(&p).unwrap();
    std::fs::remove_file(&p).ok();
    assert_eq!(s.cwd.as_deref(), Some("/Users/testuser/Projects/foo"));
    assert_eq!(s.version.as_deref(), Some("2.1.0"));
    assert_eq!(s.git_branch.as_deref(), Some("main"));
    assert_eq!(s.first_user.as_ref().unwrap().excerpt, "first q");
    assert_eq!(s.last_user.as_ref().unwrap().excerpt, "last q");
    assert_eq!(s.last_agent.as_ref().unwrap().excerpt, "last agent");
}

#[test]
fn summarize_backfills_identity_from_tail_when_head_user_lacks_it() {
    // The head's FIRST genuine user carries NO identity fields (cwd/version/branch
    // all absent), but the LAST genuine user at the tail DOES - so the tail's
    // `capture_identity_if_empty` backfills them (only the still-None fields).
    let p = tmp_session(
        "tailfill",
        &[
            // head genuine user - no identity fields at all.
            r#"{"type":"user","message":{"role":"user","content":"first q, no identity"}}"#,
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"}]}}"#,
            // tail genuine user - carries the identity fields.
            r#"{"type":"user","cwd":"/tail/cwd","version":"3.0","gitBranch":"dev","sessionId":"sid-tail","message":{"role":"user","content":"last q, has identity"}}"#,
        ],
    );
    let s = summarize_session(&p).unwrap();
    std::fs::remove_file(&p).ok();
    // The head found the first user, but identity was backfilled from the tail.
    assert_eq!(
        s.first_user.as_ref().unwrap().excerpt,
        "first q, no identity"
    );
    assert_eq!(
        s.last_user.as_ref().unwrap().excerpt,
        "last q, has identity"
    );
    assert_eq!(s.cwd.as_deref(), Some("/tail/cwd"));
    assert_eq!(s.version.as_deref(), Some("3.0"));
    assert_eq!(s.git_branch.as_deref(), Some("dev"));
}

#[test]
fn summarize_session_id_from_data_when_filename_has_no_stem() {
    // When the path has no usable stem the session id falls back to the data's
    // sessionId (the `session_id.is_empty()` true arm). We build a record carrying
    // a sessionId and drive summarize on a file, then assert the filename-stem
    // path; the data-fallback arm is also reachable when the stem is empty. Since
    // a real temp file always has a stem, assert the cross-check shape instead:
    // the id equals the stem and the data id is retained internally.
    let p = tmp_session(
        "dataid",
        &[r#"{"type":"user","sessionId":"sid-data-xyz","message":{"role":"user","content":"hi"}}"#],
    );
    let s = summarize_session(&p).unwrap();
    std::fs::remove_file(&p).ok();
    // Filename stem wins (non-empty) - the documented precedence.
    assert!(!s.session_id.is_empty());
}

#[test]
fn summarize_head_skips_non_genuine_records_before_first_user() {
    // The head stream leads with non-genuine records (metadata, an isMeta pseudo-
    // turn, a tool_result carrier) → the head closure's `genuine_user_text()`
    // returns None for each (the FALSE arm) until the real first user is reached.
    let p = tmp_session(
        "headnoise",
        &[
            r#"{"type":"last-prompt","leafUuid":"x"}"#,
            r#"{"type":"user","isMeta":true,"message":{"role":"user","content":"Continue from where you left off."}}"#,
            r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"carrier"}]}}"#,
            r#"{"type":"user","message":{"role":"user","content":"the genuine first question"}}"#,
            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"}]}}"#,
        ],
    );
    let s = summarize_session(&p).unwrap();
    std::fs::remove_file(&p).ok();
    assert_eq!(
        s.first_user.as_ref().unwrap().excerpt,
        "the genuine first question"
    );
}

#[test]
fn top_level_summary_is_not_subagent_and_is_its_own_parent() {
    // A plain `<uuid>.jsonl` path is top-level: is_subagent=false and parent==session_id.
    let p = tmp_session(
        "toplevel",
        &[r#"{"type":"user","message":{"role":"user","content":"hi"}}"#],
    );
    let s = summarize_session(&p).unwrap();
    let stem = p.file_stem().unwrap().to_str().unwrap().to_string();
    std::fs::remove_file(&p).ok();
    assert!(!s.is_subagent);
    assert_eq!(s.parent_session_id, stem);
    assert_eq!(s.parent_session_id, s.session_id);
}

#[test]
fn subagent_summary_carries_is_subagent_and_refeedable_parent() {
    // A subagent transcript path `…/<PARENT-UUID>/subagents/workflows/wf_x/agent-<hex>.jsonl`:
    // session_id is the bare hex (NOT re-feedable), is_subagent=true, and
    // parent_session_id is the re-feedable PARENT uuid (the dir before `subagents/`).
    let dir = std::env::temp_dir().join(format!(
        "csift-subdir-{}-{}/11111111-2222-3333-4444-555555555555/subagents/workflows/wf_z",
        std::process::id(),
        COUNTER.fetch_add(1, Ordering::Relaxed)
    ));
    std::fs::create_dir_all(&dir).unwrap();
    let p = dir.join("agent-deadbeefcafe1234.jsonl");
    {
        let mut f = std::fs::File::create(&p).unwrap();
        writeln!(
            f,
            r#"{{"type":"user","message":{{"role":"user","content":"sub work"}}}}"#
        )
        .unwrap();
    }
    let s = summarize_session(&p).unwrap();
    std::fs::remove_file(&p).ok();
    assert_eq!(
        s.session_id, "deadbeefcafe1234",
        "bare hex (agent- stripped)"
    );
    assert!(s.is_subagent, "a subagents/ path is a subagent transcript");
    assert_eq!(
        s.parent_session_id, "11111111-2222-3333-4444-555555555555",
        "parent is the re-feedable uuid dir before subagents/"
    );
}

#[test]
fn summarize_session_id_is_filename_stem() {
    // The session id is the jsonl basename; even when the data carries a different
    // sessionId, the filename wins (the `session_id.is_empty()` false arm).
    let p = tmp_session(
        "stemid",
        &[r#"{"type":"user","sessionId":"DATA-ID","message":{"role":"user","content":"hi"}}"#],
    );
    let s = summarize_session(&p).unwrap();
    let stem = p.file_stem().unwrap().to_str().unwrap().to_string();
    std::fs::remove_file(&p).ok();
    assert_eq!(s.session_id, stem);
    assert_ne!(s.session_id, "DATA-ID");
}