use crate::timeline::TimelineEntry;
use std::collections::HashMap;
pub const DEFAULT_THRESHOLD_LINES: usize = 8;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CollapseStats {
pub messages_collapsed: usize,
pub bytes_saved: usize,
}
impl CollapseStats {
pub fn merge(&mut self, other: &Self) {
self.messages_collapsed += other.messages_collapsed;
self.bytes_saved += other.bytes_saved;
}
}
pub fn collapse_repeats(
mut entries: Vec<TimelineEntry>,
threshold_lines: usize,
) -> (Vec<TimelineEntry>, CollapseStats) {
let mut seen: HashMap<u64, usize> = HashMap::new();
let mut stats = CollapseStats::default();
for (idx, entry) in entries.iter_mut().enumerate() {
let line_count = entry.message.lines().count();
if line_count < threshold_lines {
continue;
}
let h = hash_message(&entry.message);
if let Some(&first_idx) = seen.get(&h) {
let original_len = entry.message.len();
let stub = match detect_skill_marker(&entry.message) {
Some(name) => format!("<skill-ref: {} | first-seen: entry #{}>", name, first_idx),
None => format!(
"<dedup-ref: hash:{:08x} | first-seen: entry #{}>",
h as u32, first_idx
),
};
stats.messages_collapsed += 1;
stats.bytes_saved += original_len.saturating_sub(stub.len());
entry.message = stub;
} else {
seen.insert(h, idx);
}
}
(entries, stats)
}
pub fn detect_skill_marker(message: &str) -> Option<String> {
const MARKER: &str = "Base directory for this skill: ";
message.lines().take(8).find_map(|line| {
let trimmed = line.trim_start();
let after_quote = trimmed.trim_start_matches('>').trim_start();
let path = after_quote.strip_prefix(MARKER)?;
std::path::Path::new(path.trim())
.file_name()
.and_then(|s| s.to_str())
.map(String::from)
})
}
fn hash_message(message: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = siphasher::sip::SipHasher13::new();
message.hash(&mut h);
h.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn entry(role: &str, message: &str) -> TimelineEntry {
TimelineEntry {
timestamp: Utc::now(),
agent: "claude".into(),
session_id: "test-session".into(),
role: role.into(),
message: message.into(),
frame_kind: None,
branch: None,
cwd: None,
timestamp_source: None,
}
}
fn long_skill_body(skill: &str, suffix: &str) -> String {
let mut body = format!("Base directory for this skill: /home/u/.claude/skills/{skill}\n");
body.push_str("# SkillName\n## Purpose\n");
for i in 0..30 {
body.push_str(&format!("- bullet line {i}\n"));
}
body.push_str(suffix);
body
}
#[test]
fn collapses_repeated_skill_template() {
let body = long_skill_body("vc-ownership", "");
let entries = vec![
entry("user", &body),
entry("user", &body),
entry("user", &body),
];
let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
assert_eq!(stats.messages_collapsed, 2);
assert!(stats.bytes_saved > 0);
assert_eq!(out[0].message, body, "first occurrence kept verbatim");
assert!(
out[1].message.starts_with("<skill-ref: vc-ownership"),
"second occurrence collapsed with skill-ref stub: {}",
out[1].message
);
assert!(out[2].message.contains("first-seen: entry #0"));
}
#[test]
fn skill_marker_naming_extracted() {
let body = long_skill_body("vc-init", "");
let entries = vec![entry("user", &body), entry("user", &body)];
let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
assert!(out[1].message.contains("vc-init"));
}
#[test]
fn dedup_ref_used_when_no_skill_marker() {
let body = (0..20)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let entries = vec![entry("assistant", &body), entry("assistant", &body)];
let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
assert_eq!(stats.messages_collapsed, 1);
assert!(out[1].message.starts_with("<dedup-ref: hash:"));
assert!(out[1].message.contains("first-seen: entry #0"));
}
#[test]
fn short_messages_not_collapsed() {
let entries = vec![
entry("user", "ok\nthanks"),
entry("user", "ok\nthanks"),
entry("user", "ok\nthanks"),
];
let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
assert_eq!(stats.messages_collapsed, 0);
assert_eq!(out[1].message, "ok\nthanks");
assert_eq!(out[2].message, "ok\nthanks");
}
#[test]
fn different_long_messages_not_collapsed() {
let a = long_skill_body("vc-ownership", "different-tail-A");
let b = long_skill_body("vc-ownership", "different-tail-B");
let entries = vec![entry("user", &a), entry("user", &b)];
let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
assert_eq!(stats.messages_collapsed, 0);
assert_eq!(out[0].message, a);
assert_eq!(out[1].message, b);
}
#[test]
fn first_occurrence_index_is_stable() {
let body = long_skill_body("vc-init", "");
let entries = vec![
entry("user", "short start"),
entry("user", &body),
entry("user", "short middle"),
entry("user", &body),
];
let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
assert!(
out[3].message.contains("first-seen: entry #1"),
"stub should reference index 1 (the first long occurrence): {}",
out[3].message
);
}
#[test]
fn detect_skill_marker_plain() {
let msg = "Base directory for this skill: /home/u/.claude/skills/vc-init\n# Init\n";
assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-init"));
}
#[test]
fn detect_skill_marker_quote_prefix() {
let msg = "> Base directory for this skill: /home/u/.claude/skills/vc-ownership\n";
assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-ownership"));
}
#[test]
fn detect_skill_marker_indented_quote() {
let msg = " >> Base directory for this skill: /a/b/skills/vc-marbles\n";
assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-marbles"));
}
#[test]
fn detect_skill_marker_returns_none_for_plain_text() {
assert_eq!(detect_skill_marker("Just a regular message"), None);
assert_eq!(detect_skill_marker(""), None);
}
#[test]
fn detect_skill_marker_skipped_after_first_8_lines() {
let mut msg = String::new();
for _ in 0..10 {
msg.push_str("filler\n");
}
msg.push_str("Base directory for this skill: /a/b/c/vc-late\n");
assert_eq!(detect_skill_marker(&msg), None);
}
#[test]
fn merge_stats_is_additive() {
let mut a = CollapseStats {
messages_collapsed: 2,
bytes_saved: 1000,
};
let b = CollapseStats {
messages_collapsed: 3,
bytes_saved: 500,
};
a.merge(&b);
assert_eq!(a.messages_collapsed, 5);
assert_eq!(a.bytes_saved, 1500);
}
#[test]
fn empty_input_returns_empty() {
let (out, stats) = collapse_repeats(Vec::new(), DEFAULT_THRESHOLD_LINES);
assert!(out.is_empty());
assert_eq!(stats, CollapseStats::default());
}
}