1use crate::timeline::TimelineEntry;
17use std::collections::HashMap;
18
19pub const DEFAULT_THRESHOLD_LINES: usize = 8;
27
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
30pub struct CollapseStats {
31 pub messages_collapsed: usize,
33 pub bytes_saved: usize,
36}
37
38impl CollapseStats {
39 pub fn merge(&mut self, other: &Self) {
41 self.messages_collapsed += other.messages_collapsed;
42 self.bytes_saved += other.bytes_saved;
43 }
44}
45
46pub fn collapse_repeats(
60 mut entries: Vec<TimelineEntry>,
61 threshold_lines: usize,
62) -> (Vec<TimelineEntry>, CollapseStats) {
63 let mut seen: HashMap<u64, usize> = HashMap::new();
64 let mut stats = CollapseStats::default();
65
66 for (idx, entry) in entries.iter_mut().enumerate() {
67 let line_count = entry.message.lines().count();
68 if line_count < threshold_lines {
69 continue;
70 }
71 let h = hash_message(&entry.message);
72 if let Some(&first_idx) = seen.get(&h) {
73 let original_len = entry.message.len();
74 let stub = match detect_skill_marker(&entry.message) {
75 Some(name) => format!("<skill-ref: {} | first-seen: entry #{}>", name, first_idx),
76 None => format!(
77 "<dedup-ref: hash:{:08x} | first-seen: entry #{}>",
78 h as u32, first_idx
79 ),
80 };
81 stats.messages_collapsed += 1;
82 stats.bytes_saved += original_len.saturating_sub(stub.len());
83 entry.message = stub;
84 } else {
85 seen.insert(h, idx);
86 }
87 }
88
89 (entries, stats)
90}
91
92pub fn detect_skill_marker(message: &str) -> Option<String> {
105 const MARKER: &str = "Base directory for this skill: ";
106 message.lines().take(8).find_map(|line| {
107 let trimmed = line.trim_start();
108 let after_quote = trimmed.trim_start_matches('>').trim_start();
109 let path = after_quote.strip_prefix(MARKER)?;
110 std::path::Path::new(path.trim())
111 .file_name()
112 .and_then(|s| s.to_str())
113 .map(String::from)
114 })
115}
116
117fn hash_message(message: &str) -> u64 {
124 use std::hash::{Hash, Hasher};
125 let mut h = siphasher::sip::SipHasher13::new();
126 message.hash(&mut h);
127 h.finish()
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use chrono::Utc;
134
135 fn entry(role: &str, message: &str) -> TimelineEntry {
136 TimelineEntry {
137 timestamp: Utc::now(),
138 agent: "claude".into(),
139 session_id: "test-session".into(),
140 role: role.into(),
141 message: message.into(),
142 frame_kind: None,
143 branch: None,
144 cwd: None,
145 timestamp_source: None,
146 }
147 }
148
149 fn long_skill_body(skill: &str, suffix: &str) -> String {
150 let mut body = format!("Base directory for this skill: /home/u/.claude/skills/{skill}\n");
151 body.push_str("# SkillName\n## Purpose\n");
152 for i in 0..30 {
153 body.push_str(&format!("- bullet line {i}\n"));
154 }
155 body.push_str(suffix);
156 body
157 }
158
159 #[test]
160 fn collapses_repeated_skill_template() {
161 let body = long_skill_body("vc-ownership", "");
162 let entries = vec![
163 entry("user", &body),
164 entry("user", &body),
165 entry("user", &body),
166 ];
167 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
168
169 assert_eq!(stats.messages_collapsed, 2);
170 assert!(stats.bytes_saved > 0);
171 assert_eq!(out[0].message, body, "first occurrence kept verbatim");
172 assert!(
173 out[1].message.starts_with("<skill-ref: vc-ownership"),
174 "second occurrence collapsed with skill-ref stub: {}",
175 out[1].message
176 );
177 assert!(out[2].message.contains("first-seen: entry #0"));
178 }
179
180 #[test]
181 fn skill_marker_naming_extracted() {
182 let body = long_skill_body("vc-init", "");
183 let entries = vec![entry("user", &body), entry("user", &body)];
184 let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
185 assert!(out[1].message.contains("vc-init"));
186 }
187
188 #[test]
189 fn dedup_ref_used_when_no_skill_marker() {
190 let body = (0..20)
191 .map(|i| format!("line {i}"))
192 .collect::<Vec<_>>()
193 .join("\n");
194 let entries = vec![entry("assistant", &body), entry("assistant", &body)];
195 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
196 assert_eq!(stats.messages_collapsed, 1);
197 assert!(out[1].message.starts_with("<dedup-ref: hash:"));
198 assert!(out[1].message.contains("first-seen: entry #0"));
199 }
200
201 #[test]
202 fn short_messages_not_collapsed() {
203 let entries = vec![
205 entry("user", "ok\nthanks"),
206 entry("user", "ok\nthanks"),
207 entry("user", "ok\nthanks"),
208 ];
209 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
210 assert_eq!(stats.messages_collapsed, 0);
211 assert_eq!(out[1].message, "ok\nthanks");
212 assert_eq!(out[2].message, "ok\nthanks");
213 }
214
215 #[test]
216 fn different_long_messages_not_collapsed() {
217 let a = long_skill_body("vc-ownership", "different-tail-A");
218 let b = long_skill_body("vc-ownership", "different-tail-B");
219 let entries = vec![entry("user", &a), entry("user", &b)];
220 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
221 assert_eq!(stats.messages_collapsed, 0);
222 assert_eq!(out[0].message, a);
223 assert_eq!(out[1].message, b);
224 }
225
226 #[test]
227 fn first_occurrence_index_is_stable() {
228 let body = long_skill_body("vc-init", "");
229 let entries = vec![
230 entry("user", "short start"),
231 entry("user", &body),
232 entry("user", "short middle"),
233 entry("user", &body),
234 ];
235 let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
236 assert!(
237 out[3].message.contains("first-seen: entry #1"),
238 "stub should reference index 1 (the first long occurrence): {}",
239 out[3].message
240 );
241 }
242
243 #[test]
244 fn detect_skill_marker_plain() {
245 let msg = "Base directory for this skill: /home/u/.claude/skills/vc-init\n# Init\n";
246 assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-init"));
247 }
248
249 #[test]
250 fn detect_skill_marker_quote_prefix() {
251 let msg = "> Base directory for this skill: /home/u/.claude/skills/vc-ownership\n";
252 assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-ownership"));
253 }
254
255 #[test]
256 fn detect_skill_marker_indented_quote() {
257 let msg = " >> Base directory for this skill: /a/b/skills/vc-marbles\n";
258 assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-marbles"));
259 }
260
261 #[test]
262 fn detect_skill_marker_returns_none_for_plain_text() {
263 assert_eq!(detect_skill_marker("Just a regular message"), None);
264 assert_eq!(detect_skill_marker(""), None);
265 }
266
267 #[test]
268 fn detect_skill_marker_skipped_after_first_8_lines() {
269 let mut msg = String::new();
270 for _ in 0..10 {
271 msg.push_str("filler\n");
272 }
273 msg.push_str("Base directory for this skill: /a/b/c/vc-late\n");
274 assert_eq!(detect_skill_marker(&msg), None);
276 }
277
278 #[test]
279 fn merge_stats_is_additive() {
280 let mut a = CollapseStats {
281 messages_collapsed: 2,
282 bytes_saved: 1000,
283 };
284 let b = CollapseStats {
285 messages_collapsed: 3,
286 bytes_saved: 500,
287 };
288 a.merge(&b);
289 assert_eq!(a.messages_collapsed, 5);
290 assert_eq!(a.bytes_saved, 1500);
291 }
292
293 #[test]
294 fn empty_input_returns_empty() {
295 let (out, stats) = collapse_repeats(Vec::new(), DEFAULT_THRESHOLD_LINES);
296 assert!(out.is_empty());
297 assert_eq!(stats, CollapseStats::default());
298 }
299}