1use std::collections::{BTreeSet, HashMap};
14use std::fs::File;
15use std::io::{self, BufRead, BufReader};
16use std::path::PathBuf;
17
18use crate::cli::TimeWindow;
19use crate::store::{PromptRecord, ScanQuery, Scanner, StorePaths};
20
21pub const MIN_OCCURRENCES: usize = 2;
23
24const ID_LEN: usize = 8;
26
27const SLASH_COMMAND_MAX_CHARS: usize = 60;
30
31const PREVIEW_CHARS: usize = 64;
33
34const PROJECTS_SHOWN: usize = 3;
36
37const FURNITURE_PREFIXES: [&str; 7] = [
42 "<local-command-",
43 "<command-name>",
44 "<command-message>",
45 "<command-args>",
46 "<bash-input>",
47 "<bash-stdout>",
48 "[Request interrupted",
49];
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Action {
54 SlashCommand,
56 Skill(String),
58}
59
60impl Action {
61 pub fn label(&self) -> String {
63 match self {
64 Action::SlashCommand => "save as a slash command".to_string(),
65 Action::Skill(name) => format!("draft skill: {name}"),
66 }
67 }
68
69 pub fn kind(&self) -> &'static str {
71 match self {
72 Action::SlashCommand => "slash-command",
73 Action::Skill(_) => "skill",
74 }
75 }
76
77 pub fn skill_name(&self) -> Option<&str> {
78 match self {
79 Action::SlashCommand => None,
80 Action::Skill(name) => Some(name),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct DuplicateGroup {
88 pub id: String,
91 pub text_hash: String,
92 pub text: Option<String>,
95 pub count: usize,
96 pub last_ts: i64,
98 pub projects: Vec<String>,
100 pub action: Action,
101}
102
103impl DuplicateGroup {
104 pub fn preview(&self) -> String {
107 match &self.text {
108 Some(text) => format!("{:?}", truncate(&collapse(text), PREVIEW_CHARS)),
109 None => "(text not indexed)".to_string(),
110 }
111 }
112
113 pub fn projects_label(&self) -> String {
117 if self.projects.is_empty() {
118 return "(no project)".to_string();
119 }
120 let shown = self.projects.len().min(PROJECTS_SHOWN);
121 let label = self.projects[..shown].join(", ");
122 match self.projects.len() - shown {
123 0 => label,
124 rest => format!("{label}, +{rest} more"),
125 }
126 }
127}
128
129pub fn group_id(text_hash: &str) -> String {
133 text_hash.chars().take(ID_LEN).collect()
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct Detection {
140 pub groups: Vec<DuplicateGroup>,
142 pub furniture: usize,
144}
145
146pub fn detect(
151 scanner: &Scanner,
152 window: TimeWindow,
153 project: Option<&str>,
154) -> io::Result<Detection> {
155 let events = event_index(scanner, window, project)?;
156 if events.is_empty() {
157 return Ok(Detection::default());
158 }
159
160 let mut groups: HashMap<String, Accumulator> = HashMap::new();
161 for path in prompt_partitions(scanner, window)? {
162 let file = match File::open(&path) {
163 Ok(file) => file,
164 Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
166 Err(err) => return Err(err),
167 };
168 for line in BufReader::new(file).lines() {
169 let line = line?;
170 if line.trim().is_empty() {
171 continue;
172 }
173 let Ok(prompt) = serde_json::from_str::<PromptRecord>(&line) else {
175 continue;
176 };
177 let Some(placement) = events.get(&prompt.event_id) else {
180 continue;
181 };
182 groups
183 .entry(prompt.text_hash.clone())
184 .or_insert_with(|| Accumulator::new(&prompt.text_hash))
185 .observe(&prompt, placement);
186 }
187 }
188
189 let repeated = groups
190 .into_values()
191 .filter(|acc| acc.count >= MIN_OCCURRENCES)
192 .map(Accumulator::finish);
193
194 let mut detection = Detection::default();
195 for group in repeated {
196 if group.text.as_deref().is_some_and(is_furniture) {
197 detection.furniture += 1;
198 } else {
199 detection.groups.push(group);
200 }
201 }
202 detection.groups.sort_by(|a, b| {
203 b.count
204 .cmp(&a.count)
205 .then_with(|| b.last_ts.cmp(&a.last_ts))
206 .then_with(|| a.id.cmp(&b.id))
207 });
208 Ok(detection)
209}
210
211fn is_furniture(text: &str) -> bool {
218 let trimmed = text.trim();
219 if FURNITURE_PREFIXES
220 .iter()
221 .any(|prefix| trimmed.starts_with(prefix))
222 {
223 return true;
224 }
225 trimmed.starts_with('/') && !trimmed.contains(char::is_whitespace)
227}
228
229#[derive(Debug, Clone)]
231struct Placement {
232 ts: i64,
233 project: Option<String>,
234}
235
236fn event_index(
238 scanner: &Scanner,
239 window: TimeWindow,
240 project: Option<&str>,
241) -> io::Result<HashMap<String, Placement>> {
242 let query = ScanQuery::new(window).with_project(project.map(str::to_string));
243 let mut index = HashMap::new();
244 scanner.scan_with(&query, |event| {
245 index.insert(
246 event.id,
247 Placement {
248 ts: event.ts,
249 project: event.project,
250 },
251 );
252 })?;
253 Ok(index)
254}
255
256fn prompt_partitions(scanner: &Scanner, window: TimeWindow) -> io::Result<Vec<PathBuf>> {
262 let found = StorePaths::partitions_in(&scanner.paths().prompts_dir(), window)?;
263 Ok(found.into_iter().map(|(_, path)| path).collect())
264}
265
266#[derive(Debug)]
268struct Accumulator {
269 text_hash: String,
270 text: Option<String>,
271 count: usize,
272 last_ts: i64,
273 projects: BTreeSet<String>,
274}
275
276impl Accumulator {
277 fn new(text_hash: &str) -> Self {
278 Self {
279 text_hash: text_hash.to_string(),
280 text: None,
281 count: 0,
282 last_ts: i64::MIN,
283 projects: BTreeSet::new(),
284 }
285 }
286
287 fn observe(&mut self, prompt: &PromptRecord, placement: &Placement) {
288 self.count += 1;
289 self.last_ts = self.last_ts.max(placement.ts);
290 if self.text.is_none() {
291 self.text.clone_from(&prompt.text);
293 }
294 if let Some(project) = &placement.project {
295 self.projects.insert(project.clone());
296 }
297 }
298
299 fn finish(self) -> DuplicateGroup {
300 let action = action_for(self.text.as_deref());
301 DuplicateGroup {
302 id: group_id(&self.text_hash),
303 text_hash: self.text_hash,
304 text: self.text,
305 count: self.count,
306 last_ts: self.last_ts,
307 projects: self.projects.into_iter().collect(),
308 action,
309 }
310 }
311}
312
313fn action_for(text: Option<&str>) -> Action {
316 let Some(text) = text else {
317 return Action::Skill("repeated-prompt".to_string());
318 };
319 let trimmed = text.trim();
320 if !trimmed.contains('\n') && trimmed.chars().count() <= SLASH_COMMAND_MAX_CHARS {
321 Action::SlashCommand
322 } else {
323 Action::Skill(slug(trimmed))
324 }
325}
326
327fn slug(text: &str) -> String {
329 const STOPWORDS: [&str; 12] = [
330 "a", "an", "and", "any", "for", "in", "of", "on", "the", "then", "to", "with",
331 ];
332 const MAX_WORDS: usize = 4;
333
334 let words: Vec<String> = text
335 .split(|c: char| !c.is_ascii_alphanumeric())
336 .map(str::to_lowercase)
337 .filter(|word| !word.is_empty() && !STOPWORDS.contains(&word.as_str()))
338 .take(MAX_WORDS)
339 .collect();
340
341 if words.is_empty() {
342 "repeated-prompt".to_string()
343 } else {
344 words.join("-")
345 }
346}
347
348pub fn format_age(now_ms: i64, then_ms: i64) -> String {
351 let secs = (now_ms - then_ms).max(0) / 1000;
352 match secs {
353 s if s < 60 => "just now".to_string(),
354 s if s < 3_600 => format!("{}m ago", s / 60),
355 s if s < 86_400 => format!("{}h ago", s / 3_600),
356 s => format!("{}d ago", s / 86_400),
357 }
358}
359
360pub fn draft(group: &DuplicateGroup, now_ms: i64) -> String {
363 let name = group
364 .action
365 .skill_name()
366 .map(str::to_string)
367 .unwrap_or_else(|| slug(group.text.as_deref().unwrap_or("")));
368 let projects = group.projects_label();
369 let age = format_age(now_ms, group.last_ts);
370 let body = match &group.text {
371 Some(text) => format!(
372 "## The prompt\n\nRepeated verbatim {} times; last {age}. Projects: {projects}.\n\n\
373 ```\n{}\n```\n\n## Steps\n\n1. Restate the request in your own words.\n2. Do the work \
374 the prompt describes.\n3. Report what changed.\n\nReplace the steps above with the \
375 procedure you actually follow — warden can see that you repeat this prompt, not what \
376 you do about it.\n",
377 group.count,
378 text.trim()
379 ),
380 None => format!(
381 "## The prompt\n\nRepeated {} times; last {age}. Projects: {projects}.\n\nThe prompt \
382 text was not stored (`general.index_prompt_text = false`), so warden can report the \
383 repetition but not the wording. Paste the prompt here yourself, then write the \
384 procedure below.\n\n## Steps\n\n1. …\n",
385 group.count
386 ),
387 };
388
389 format!(
390 "---\nname: {name}\ndescription: Repeated prompt detected by warden ({} occurrences, \
391 last {age}).\n---\n\n{body}\n<!-- warden: id {} · text_hash {} · this draft was printed, \
392 not written; warden does not create files -->\n",
393 group.count, group.id, group.text_hash
394 )
395}
396
397fn collapse(text: &str) -> String {
400 text.split_whitespace().collect::<Vec<_>>().join(" ")
401}
402
403fn truncate(text: &str, max: usize) -> String {
404 if text.chars().count() <= max {
405 return text.to_string();
406 }
407 let head: String = text.chars().take(max.saturating_sub(1)).collect();
408 format!("{}…", head.trim_end())
409}
410
411#[cfg(test)]
412pub(crate) mod testkit {
413 use crate::store::{text_hash, Event, PromptRecord, StorePaths, StoreWriter};
414 use chrono::{TimeZone, Utc};
415
416 pub fn ms(y: i32, mo: u32, d: u32, h: u32) -> i64 {
417 Utc.with_ymd_and_hms(y, mo, d, h, 0, 0)
418 .unwrap()
419 .timestamp_millis()
420 }
421
422 pub fn store(
425 prompts: &[(&str, i64, &str, &str)],
426 index_text: bool,
427 ) -> (tempfile::TempDir, StorePaths) {
428 let dir = tempfile::tempdir().unwrap();
429 let paths = StorePaths::new(dir.path());
430 let mut writer = StoreWriter::open(paths.clone()).unwrap();
431 for (id, ts, project, text) in prompts {
432 let mut event = Event::new(*id, *ts, "claude-code", "anthropic", "user");
433 event.project = Some((*project).to_string());
434 writer.append_event(&event).unwrap();
435 writer
436 .append_prompt(
437 *ts,
438 &PromptRecord {
439 event_id: (*id).to_string(),
440 text: index_text.then(|| (*text).to_string()),
441 text_hash: text_hash(text),
442 },
443 )
444 .unwrap();
445 }
446 (dir, paths)
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::testkit::*;
453 use super::*;
454 use crate::store::{text_hash, StorePaths};
455
456 const RUN_TESTS: &str = "run the test suite and fix any failures";
457 const LONG: &str = "check every migration file in db/migrate for a missing down() and write \
458 one where it is absent";
459
460 fn fixture(index_text: bool) -> (tempfile::TempDir, StorePaths) {
461 store(
462 &[
463 ("a", ms(2026, 8, 1, 9), "acme-api", RUN_TESTS),
464 ("b", ms(2026, 8, 2, 9), "acme-api", RUN_TESTS),
465 ("c", ms(2026, 8, 3, 9), "warden", RUN_TESTS),
466 ("d", ms(2026, 8, 3, 10), "acme-api", LONG),
467 ("e", ms(2026, 8, 3, 11), "acme-api", LONG),
468 ("f", ms(2026, 8, 3, 12), "acme-api", "a one-off question"),
469 ],
470 index_text,
471 )
472 }
473
474 fn detected(index_text: bool) -> Vec<DuplicateGroup> {
475 let (_dir, paths) = fixture(index_text);
476 detect(&Scanner::new(paths), TimeWindow::all(), None)
477 .unwrap()
478 .groups
479 }
480
481 #[test]
482 fn groups_exact_duplicates_and_ignores_one_offs() {
483 let groups = detected(true);
484 assert_eq!(groups.len(), 2, "{groups:#?}");
485
486 let top = &groups[0];
487 assert_eq!(top.count, 3);
488 assert_eq!(top.text.as_deref(), Some(RUN_TESTS));
489 assert_eq!(top.last_ts, ms(2026, 8, 3, 9));
490 assert_eq!(top.projects, vec!["acme-api", "warden"]);
491 assert_eq!(top.action, Action::SlashCommand);
492
493 let second = &groups[1];
494 assert_eq!(second.count, 2);
495 assert_eq!(
496 second.action,
497 Action::Skill("check-every-migration-file".into())
498 );
499 }
500
501 #[test]
502 fn detects_duplicates_with_prompt_text_disabled() {
503 let groups = detected(false);
504 assert_eq!(groups.len(), 2);
505 assert_eq!(groups[0].count, 3);
506 assert!(groups[0].text.is_none());
507 assert_eq!(groups[0].preview(), "(text not indexed)");
509 let with_text = detected(true);
511 assert_eq!(groups[0].id, with_text[0].id);
512 }
513
514 #[test]
515 fn ids_are_stable_across_runs_and_derived_from_the_hash() {
516 let first = detected(true);
517 let second = detected(true);
518 let ids: Vec<&str> = first.iter().map(|g| g.id.as_str()).collect();
519 let again: Vec<&str> = second.iter().map(|g| g.id.as_str()).collect();
520 assert_eq!(ids, again);
521 assert_eq!(first[0].id, group_id(&text_hash(RUN_TESTS)));
522 assert_eq!(first[0].id.len(), ID_LEN);
523 assert!(first[0].text_hash.starts_with(&first[0].id));
524 }
525
526 #[test]
527 fn honours_the_window_and_the_project_filter() {
528 let (_dir, paths) = fixture(true);
529 let scanner = Scanner::new(paths);
530
531 let window = TimeWindow::new(ms(2026, 8, 3, 0), ms(2026, 8, 4, 0));
533 let groups = detect(&scanner, window, None).unwrap().groups;
534 assert_eq!(groups.len(), 1);
535 assert_eq!(groups[0].count, 2);
536
537 let groups = detect(&scanner, TimeWindow::all(), Some("acme-api"))
539 .unwrap()
540 .groups;
541 let run_tests = groups
542 .iter()
543 .find(|g| g.text.as_deref() == Some(RUN_TESTS))
544 .unwrap();
545 assert_eq!(run_tests.count, 2);
546 assert_eq!(run_tests.projects, vec!["acme-api"]);
547 }
548
549 #[test]
550 fn an_empty_store_suggests_nothing() {
551 let dir = tempfile::tempdir().unwrap();
552 let scanner = Scanner::new(StorePaths::new(dir.path()));
553 let detection = detect(&scanner, TimeWindow::all(), None).unwrap();
554 assert_eq!(detection, Detection::default());
555 }
556
557 #[test]
558 fn client_transcript_furniture_is_set_aside_and_counted() {
559 let (_dir, paths) = store(
560 &[
561 ("a", ms(2026, 8, 1, 9), "acme-api", RUN_TESTS),
562 ("b", ms(2026, 8, 1, 10), "acme-api", RUN_TESTS),
563 ("c", ms(2026, 8, 1, 11), "acme-api", "/compact"),
564 ("d", ms(2026, 8, 1, 12), "acme-api", "/compact"),
565 (
566 "e",
567 ms(2026, 8, 1, 13),
568 "acme-api",
569 "<command-name>/clear</command-name>",
570 ),
571 (
572 "f",
573 ms(2026, 8, 1, 14),
574 "acme-api",
575 "<command-name>/clear</command-name>",
576 ),
577 (
578 "g",
579 ms(2026, 8, 1, 15),
580 "acme-api",
581 "[Request interrupted by user]",
582 ),
583 (
584 "h",
585 ms(2026, 8, 1, 16),
586 "acme-api",
587 "[Request interrupted by user]",
588 ),
589 ],
590 true,
591 );
592 let detection = detect(&Scanner::new(paths), TimeWindow::all(), None).unwrap();
593 assert_eq!(detection.groups.len(), 1);
594 assert_eq!(detection.groups[0].text.as_deref(), Some(RUN_TESTS));
595 assert_eq!(detection.furniture, 3);
596 }
597
598 #[test]
599 fn a_prompt_that_merely_mentions_a_marker_is_still_a_prompt() {
600 assert!(!is_furniture(
601 "explain what <command-name> means in the log"
602 ));
603 assert!(!is_furniture("/simplify the parser and then run the tests"));
604 assert!(is_furniture(
605 " <local-command-stdout>ok</local-command-stdout>"
606 ));
607 assert!(is_furniture("/compact"));
608 }
609
610 #[test]
611 fn the_projects_cell_is_bounded_but_the_list_is_not() {
612 let group = DuplicateGroup {
613 id: "abcd1234".into(),
614 text_hash: "abcd1234".into(),
615 text: None,
616 count: 2,
617 last_ts: 0,
618 projects: vec!["a".into(), "b".into(), "c".into(), "d".into(), "e".into()],
619 action: Action::SlashCommand,
620 };
621 assert_eq!(group.projects_label(), "a, b, c, +2 more");
622 assert_eq!(group.projects.len(), 5, "the full list survives for --json");
623 }
624
625 #[test]
626 fn a_draft_mentions_the_prompt_the_count_and_that_nothing_was_written() {
627 let groups = detected(true);
628 let now = ms(2026, 8, 5, 9);
629 let text = draft(&groups[1], now);
630 assert!(
631 text.starts_with("---\nname: check-every-migration-file\n"),
632 "{text}"
633 );
634 assert!(text.contains("Repeated verbatim 2 times"), "{text}");
635 assert!(text.contains("1d ago"), "{text}");
636 assert!(text.contains(&groups[1].text_hash), "{text}");
637 assert!(text.contains("not written"), "{text}");
638 }
639
640 #[test]
641 fn a_draft_without_text_says_so_instead_of_inventing_a_prompt() {
642 let groups = detected(false);
643 let text = draft(&groups[0], ms(2026, 8, 5, 9));
644 assert!(text.contains("index_prompt_text = false"), "{text}");
645 assert!(text.contains("Repeated 3 times"), "{text}");
646 }
647
648 #[test]
649 fn previews_are_one_line_and_bounded() {
650 let group = DuplicateGroup {
651 id: "abcd1234".into(),
652 text_hash: "abcd1234".into(),
653 text: Some(format!("first line\nsecond line {}", "x".repeat(200))),
654 count: 2,
655 last_ts: 0,
656 projects: Vec::new(),
657 action: Action::SlashCommand,
658 };
659 let preview = group.preview();
660 assert!(!preview.contains('\n'), "{preview}");
661 assert!(preview.chars().count() <= PREVIEW_CHARS + 3, "{preview}");
662 assert_eq!(group.projects_label(), "(no project)");
663 }
664
665 #[test]
666 fn ages_are_coarse_and_never_negative() {
667 assert_eq!(format_age(1_000_000, 1_000_000), "just now");
668 assert_eq!(format_age(1_000_000, 999_000), "just now");
669 assert_eq!(format_age(3_600_000, 0), "1h ago");
670 assert_eq!(format_age(90_000, 0), "1m ago");
671 assert_eq!(format_age(172_800_000, 0), "2d ago");
672 assert_eq!(format_age(0, 5_000), "just now");
674 }
675
676 #[test]
677 fn slugs_drop_stopwords_and_punctuation() {
678 assert_eq!(slug("check the migration files"), "check-migration-files");
679 assert_eq!(slug("run it!!"), "run-it");
680 assert_eq!(slug("...???"), "repeated-prompt");
681 }
682}