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 source_path: None,
147 source_sha256: None,
148 source_line_span: None,
149 }
150 }
151
152 fn long_skill_body(skill: &str, suffix: &str) -> String {
153 let mut body =
154 format!("Base directory for this skill: /home/user/.claude/skills/{skill}\n");
155 body.push_str("# SkillName\n## Purpose\n");
156 for i in 0..30 {
157 body.push_str(&format!("- bullet line {i}\n"));
158 }
159 body.push_str(suffix);
160 body
161 }
162
163 #[test]
164 fn collapses_repeated_skill_template() {
165 let body = long_skill_body("vc-ownership", "");
166 let entries = vec![
167 entry("user", &body),
168 entry("user", &body),
169 entry("user", &body),
170 ];
171 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
172
173 assert_eq!(stats.messages_collapsed, 2);
174 assert!(stats.bytes_saved > 0);
175 assert_eq!(out[0].message, body, "first occurrence kept verbatim");
176 assert!(
177 out[1].message.starts_with("<skill-ref: vc-ownership"),
178 "second occurrence collapsed with skill-ref stub: {}",
179 out[1].message
180 );
181 assert!(out[2].message.contains("first-seen: entry #0"));
182 }
183
184 #[test]
185 fn skill_marker_naming_extracted() {
186 let body = long_skill_body("vc-init", "");
187 let entries = vec![entry("user", &body), entry("user", &body)];
188 let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
189 assert!(out[1].message.contains("vc-init"));
190 }
191
192 #[test]
193 fn dedup_ref_used_when_no_skill_marker() {
194 let body = (0..20)
195 .map(|i| format!("line {i}"))
196 .collect::<Vec<_>>()
197 .join("\n");
198 let entries = vec![entry("assistant", &body), entry("assistant", &body)];
199 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
200 assert_eq!(stats.messages_collapsed, 1);
201 assert!(out[1].message.starts_with("<dedup-ref: hash:"));
202 assert!(out[1].message.contains("first-seen: entry #0"));
203 }
204
205 #[test]
206 fn short_messages_not_collapsed() {
207 let entries = vec![
209 entry("user", "ok\nthanks"),
210 entry("user", "ok\nthanks"),
211 entry("user", "ok\nthanks"),
212 ];
213 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
214 assert_eq!(stats.messages_collapsed, 0);
215 assert_eq!(out[1].message, "ok\nthanks");
216 assert_eq!(out[2].message, "ok\nthanks");
217 }
218
219 #[test]
220 fn different_long_messages_not_collapsed() {
221 let a = long_skill_body("vc-ownership", "different-tail-A");
222 let b = long_skill_body("vc-ownership", "different-tail-B");
223 let entries = vec![entry("user", &a), entry("user", &b)];
224 let (out, stats) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
225 assert_eq!(stats.messages_collapsed, 0);
226 assert_eq!(out[0].message, a);
227 assert_eq!(out[1].message, b);
228 }
229
230 #[test]
231 fn first_occurrence_index_is_stable() {
232 let body = long_skill_body("vc-init", "");
233 let entries = vec![
234 entry("user", "short start"),
235 entry("user", &body),
236 entry("user", "short middle"),
237 entry("user", &body),
238 ];
239 let (out, _) = collapse_repeats(entries, DEFAULT_THRESHOLD_LINES);
240 assert!(
241 out[3].message.contains("first-seen: entry #1"),
242 "stub should reference index 1 (the first long occurrence): {}",
243 out[3].message
244 );
245 }
246
247 #[test]
248 fn detect_skill_marker_plain() {
249 let msg = "Base directory for this skill: /home/user/.claude/skills/vc-init\n# Init\n";
250 assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-init"));
251 }
252
253 #[test]
254 fn detect_skill_marker_quote_prefix() {
255 let msg = "> Base directory for this skill: /home/user/.claude/skills/vc-ownership\n";
256 assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-ownership"));
257 }
258
259 #[test]
260 fn detect_skill_marker_indented_quote() {
261 let msg = " >> Base directory for this skill: /a/b/skills/vc-marbles\n";
262 assert_eq!(detect_skill_marker(msg).as_deref(), Some("vc-marbles"));
263 }
264
265 #[test]
266 fn detect_skill_marker_returns_none_for_plain_text() {
267 assert_eq!(detect_skill_marker("Just a regular message"), None);
268 assert_eq!(detect_skill_marker(""), None);
269 }
270
271 #[test]
272 fn detect_skill_marker_skipped_after_first_8_lines() {
273 let mut msg = String::new();
274 for _ in 0..10 {
275 msg.push_str("filler\n");
276 }
277 msg.push_str("Base directory for this skill: /a/b/c/vc-late\n");
278 assert_eq!(detect_skill_marker(&msg), None);
280 }
281
282 #[test]
283 fn merge_stats_is_additive() {
284 let mut a = CollapseStats {
285 messages_collapsed: 2,
286 bytes_saved: 1000,
287 };
288 let b = CollapseStats {
289 messages_collapsed: 3,
290 bytes_saved: 500,
291 };
292 a.merge(&b);
293 assert_eq!(a.messages_collapsed, 5);
294 assert_eq!(a.bytes_saved, 1500);
295 }
296
297 #[test]
298 fn empty_input_returns_empty() {
299 let (out, stats) = collapse_repeats(Vec::new(), DEFAULT_THRESHOLD_LINES);
300 assert!(out.is_empty());
301 assert_eq!(stats, CollapseStats::default());
302 }
303}