Skip to main content

warden/
suggest.rs

1//! Exact-duplicate prompt detection.
2//!
3//! One detector, deliberately: prompts are grouped by `text_hash` and nothing
4//! else. No embeddings, no fuzzy matching, and therefore no false positives —
5//! every group warden reports is a byte-identical prompt you really did send
6//! more than once.
7//!
8//! `prompts/` is the only place prompt text lives, so this is the one read path
9//! that opens it. The join back to `events/` (via the shared scanner) is what
10//! supplies the timestamp and project a `PromptRecord` deliberately does not
11//! carry, and it is also what makes `--since` and `--project` apply here.
12
13use 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
21/// How many times a prompt must appear before it is worth reporting.
22pub const MIN_OCCURRENCES: usize = 2;
23
24/// Length of the short `<id>` that `--draft` addresses.
25const ID_LEN: usize = 8;
26
27/// A prompt short enough to retype is worth an alias; a longer one is carrying
28/// a procedure and wants a skill. Documented because it is a judgement call.
29const SLASH_COMMAND_MAX_CHARS: usize = 60;
30
31/// Width the prompt is truncated to for one-line display.
32const PREVIEW_CHARS: usize = 64;
33
34/// Projects named in a table cell before the rest become a count.
35const PROJECTS_SHOWN: usize = 3;
36
37/// Markers of transcript furniture the *client* wrote into the log — slash
38/// command expansions, compaction notices, bash echoes, interrupt markers. They
39/// repeat constantly and are not prompts anyone typed, so suggesting you alias
40/// them is noise. They are counted and reported, never silently dropped.
41const 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/// What warden suggests doing about a repeated prompt.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Action {
54    /// Short and literal: alias it.
55    SlashCommand,
56    /// Longer, or unknown text: draft a skill with this name.
57    Skill(String),
58}
59
60impl Action {
61    /// The `→` line in table output.
62    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    /// Stable machine-readable kind for `--json`.
70    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/// One set of byte-identical prompts.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct DuplicateGroup {
88    /// Short, stable handle for `--draft`. Derived from `text_hash`, so it is
89    /// the same on every run and on every machine with the same prompt.
90    pub id: String,
91    pub text_hash: String,
92    /// Absent when `general.index_prompt_text = false`: the hash still groups,
93    /// but there is no text to show.
94    pub text: Option<String>,
95    pub count: usize,
96    /// Most recent occurrence, epoch ms.
97    pub last_ts: i64,
98    /// Projects the prompt was sent from, sorted; empty if none were recorded.
99    pub projects: Vec<String>,
100    pub action: Action,
101}
102
103impl DuplicateGroup {
104    /// One line of the prompt, whitespace-collapsed and truncated, or an
105    /// explicit statement that the text was never stored.
106    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    /// Projects for a table cell: a few names, then a count. A prompt repeated
114    /// across two dozen repos would otherwise be one unreadable row. `--json`
115    /// keeps the full list.
116    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
129/// The id a user types at `warden suggest --draft <id>`. Deterministic — same
130/// hash, same id, always — so an id printed by one run still resolves in the
131/// next. Distinct from `reports::short_id`, which only truncates for display.
132pub fn group_id(text_hash: &str) -> String {
133    text_hash.chars().take(ID_LEN).collect()
134}
135
136/// The result of a scan: the repeated prompts worth acting on, and a count of
137/// what was set aside.
138#[derive(Debug, Clone, Default, PartialEq, Eq)]
139pub struct Detection {
140    /// Most-repeated first.
141    pub groups: Vec<DuplicateGroup>,
142    /// Repeated groups that were client transcript furniture, not prompts.
143    pub furniture: usize,
144}
145
146/// Find every prompt sent more than once in the window.
147///
148/// Ordering is most-repeated first, then most-recent, then by id — total, so
149/// two runs over the same store print the same list in the same order.
150pub 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            // A partition can vanish between listing and opening.
165            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            // A torn or future-shaped line is skipped, as everywhere else.
174            let Ok(prompt) = serde_json::from_str::<PromptRecord>(&line) else {
175                continue;
176            };
177            // The event index is the filter: a prompt whose event is outside
178            // the window or the project simply is not there.
179            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
211/// Whether a prompt is something the client wrote into the transcript rather
212/// than something a person typed.
213///
214/// Only exact, structural markers count — a prompt that merely *mentions* one
215/// is a real prompt. With `index_prompt_text = false` there is no text to
216/// judge, so nothing is filtered and the list is simply noisier.
217fn 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    // A prompt that is already just a slash command needs no suggestion.
226    trimmed.starts_with('/') && !trimmed.contains(char::is_whitespace)
227}
228
229/// Where a prompt's event sits: its time, and the project it came from.
230#[derive(Debug, Clone)]
231struct Placement {
232    ts: i64,
233    project: Option<String>,
234}
235
236/// `event_id → placement`, for every event the window and project filter admit.
237fn 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
256/// Prompt partitions overlapping the window, chronologically.
257///
258/// Prompts are partitioned exactly like events, so this is the event scanner's
259/// overlap rule rather than a second copy of it: `--since 7d` opens one or two
260/// files rather than the whole of `prompts/`.
261fn 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/// Running state for one `text_hash` while scanning.
267#[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            // Identical hash, identical text: the first one seen will do.
292            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
313/// Short single-line prompts are aliases; anything longer, or anything whose
314/// text was never stored, gets a skill draft.
315fn 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
327/// A kebab-case skill name from the prompt's first few meaningful words.
328fn 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
348/// `2d ago`, `5h ago`, `just now` — coarse on purpose; the point is recency,
349/// not precision.
350pub 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
360/// The `SKILL.md` a `--draft` prints. Returned as a string: the scope is explicit
361/// that warden does not write it anywhere.
362pub 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
397/// Collapse all whitespace runs to single spaces, so a multi-line prompt still
398/// fits on one row.
399fn 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    /// A store holding one user event per `(id, ts, project, text)`, with the
423    /// matching prompt record. `index_text` mirrors `index_prompt_text`.
424    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        // The user is told why there is no text rather than shown a blank.
508        assert_eq!(groups[0].preview(), "(text not indexed)");
509        // And the id is the same as it is with text on: it comes from the hash.
510        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        // Only the last day: two occurrences of LONG, one of RUN_TESTS.
532        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        // Only acme-api: the warden occurrence of RUN_TESTS drops out.
538        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        // A clock skew must not produce "-3d ago".
673        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}