Skip to main content

warden/commands/
suggest.rs

1//! `warden suggest`.
2//!
3//! Two modes over one detector: a list of repeated prompts, and the `SKILL.md`
4//! draft for one of them. The draft goes to **stdout and nowhere else** — warden
5//! is explicit that writing files waits for v0.2, so this module opens no file
6//! for writing and creates no directory.
7
8use std::io::{self, Write};
9
10use chrono::Utc;
11
12use crate::output::{emit, Cell, Report, Table};
13use crate::store::Scanner;
14use crate::suggest::{self, Detection, DuplicateGroup, MIN_OCCURRENCES};
15
16use super::Env;
17
18/// Rows the table shows before it stops being readable. `--json` is never
19/// truncated: a harness wants the whole list.
20const TABLE_ROWS: usize = 20;
21
22/// What a run produced, so callers (and tests) can assert on it.
23#[derive(Debug, Clone)]
24pub enum Outcome {
25    /// The list of repeated prompts.
26    Listed(Report),
27    /// A `SKILL.md`, exactly as printed.
28    Drafted(String),
29}
30
31/// List repeated prompts, or print one draft.
32pub fn run(env: &Env<'_>, draft_id: Option<&str>) -> io::Result<Outcome> {
33    env.pre_ingest()?;
34    let detection = suggest::detect(&Scanner::new(env.paths.clone()), env.window, env.project)?;
35    let groups = &detection.groups;
36    let now = Utc::now().timestamp_millis();
37
38    match draft_id {
39        Some(id) => {
40            let group = find(groups, id)?;
41            let text = suggest::draft(group, now);
42            // The one deliberate non-`emit` path: the draft *is* the payload, so
43            // `warden suggest --draft <id> > SKILL.md` stays byte-identical. The
44            // parser forbids `--json` here, so there is nothing to branch on.
45            let mut stdout = io::stdout().lock();
46            write!(stdout, "{text}")?;
47            stdout.flush()?;
48            Ok(Outcome::Drafted(text))
49        }
50        None => {
51            let report = build(&detection, env.window, now);
52            headline(&mut io::stderr().lock(), groups.len())?;
53            emit(&report, env.json)?;
54            Ok(Outcome::Listed(report))
55        }
56    }
57}
58
59/// Resolve a `--draft` argument against the detected groups.
60///
61/// Accepts the short id or any unambiguous prefix of the full `text_hash`, and
62/// says what the valid ids are when it cannot.
63fn find<'a>(groups: &'a [DuplicateGroup], id: &str) -> io::Result<&'a DuplicateGroup> {
64    let id = id.trim();
65    let matches: Vec<&DuplicateGroup> = groups
66        .iter()
67        .filter(|group| group.id == id || group.text_hash.starts_with(id))
68        .collect();
69
70    match matches.as_slice() {
71        [group] => Ok(group),
72        [] => Err(io::Error::new(
73            io::ErrorKind::InvalidInput,
74            format!(
75                "no repeated prompt with id {id:?} in this window{}\nrun `warden suggest` to see \
76                 the ids, or widen --since",
77                known_ids(groups)
78            ),
79        )),
80        _ => Err(io::Error::new(
81            io::ErrorKind::InvalidInput,
82            format!(
83                "id {id:?} is ambiguous; use the full 8-character id{}",
84                known_ids(groups)
85            ),
86        )),
87    }
88}
89
90fn known_ids(groups: &[DuplicateGroup]) -> String {
91    if groups.is_empty() {
92        return String::new();
93    }
94    let ids: Vec<&str> = groups.iter().map(|group| group.id.as_str()).collect();
95    format!(" (known ids: {})", ids.join(", "))
96}
97
98/// `3 repeated prompts found`. Goes to **stderr** so stdout carries the report
99/// alone, as `Env::pre_ingest` does with its progress.
100fn headline<W: Write>(out: &mut W, count: usize) -> io::Result<()> {
101    if count == 0 {
102        return writeln!(
103            out,
104            "no repeated prompts found (a prompt must appear at least {MIN_OCCURRENCES}x)\n"
105        );
106    }
107    let plural = if count == 1 { "prompt" } else { "prompts" };
108    let capped = if count > TABLE_ROWS {
109        format!(", showing the {TABLE_ROWS} most repeated — --json has them all")
110    } else {
111        String::new()
112    };
113    writeln!(out, "{count} repeated {plural} found{capped}\n")
114}
115
116fn build(detection: &Detection, window: crate::cli::TimeWindow, now: i64) -> Report {
117    let groups = &detection.groups;
118    let mut table = Table::new(["id", "count", "last", "projects", "suggestion", "prompt"]);
119    let mut rows = Vec::new();
120    let mut any_unindexed = false;
121
122    for (rank, group) in groups.iter().enumerate() {
123        any_unindexed |= group.text.is_none();
124        if rank < TABLE_ROWS {
125            table.push(vec![
126                Cell::text(&group.id),
127                Cell::Int(i64::try_from(group.count).unwrap_or(i64::MAX)),
128                Cell::text(suggest::format_age(now, group.last_ts)),
129                Cell::text(group.projects_label()),
130                Cell::text(format!("→ {}", group.action.label())),
131                Cell::text(group.preview()),
132            ]);
133        }
134
135        rows.push(serde_json::json!({
136            "id": group.id,
137            "text_hash": group.text_hash,
138            "count": group.count,
139            "text": group.text,
140            "text_indexed": group.text.is_some(),
141            "last_ts": group.last_ts,
142            "last_age": suggest::format_age(now, group.last_ts),
143            "projects": group.projects,
144            "suggestion": {
145                "kind": group.action.kind(),
146                "skill_name": group.action.skill_name(),
147            },
148        }));
149    }
150
151    let mut notes = vec![
152        format!(
153            "prompts are grouped by exact text hash — no fuzzy matching, so every group is a \
154             byte-identical repeat of at least {MIN_OCCURRENCES} occurrences"
155        ),
156        "a short single-line prompt is suggested as a slash command; a longer one gets a skill \
157         draft — run `warden suggest --draft <id>` to print it"
158            .to_string(),
159    ];
160    if any_unindexed {
161        notes.push(
162            "some prompts show no text: `general.index_prompt_text = false` stores only the hash, \
163             which still detects repeats but cannot show the wording"
164                .to_string(),
165        );
166    }
167    if detection.furniture > 0 {
168        notes.push(format!(
169            "{} repeated groups were set aside as client transcript furniture (slash-command \
170             expansions, compaction notices, bash echoes, interrupt markers) — repeated, but not \
171             prompts anyone typed",
172            detection.furniture
173        ));
174    }
175    if groups.len() > TABLE_ROWS {
176        notes.push(format!(
177            "the table shows the {TABLE_ROWS} most repeated of {}; these JSON rows are complete",
178            groups.len()
179        ));
180    }
181    if groups.is_empty() {
182        notes.push(
183            "nothing repeated in this window; widen --since, or check `warden doctor` if \
184             prompts are not being ingested at all"
185                .to_string(),
186        );
187    }
188
189    Report::new("suggest", window, table)
190        .with_json_rows(rows)
191        .with_notes(notes)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::cli::TimeWindow;
198    use crate::config::Config;
199    use crate::output::Style;
200    use crate::store::StorePaths;
201    use crate::suggest::testkit::{ms, store};
202
203    const RUN_TESTS: &str = "run the test suite and fix any failures";
204
205    fn env<'a>(paths: &'a StorePaths, config: &'a Config) -> Env<'a> {
206        Env {
207            config,
208            paths,
209            window: TimeWindow::all(),
210            project: None,
211            json: false,
212            no_ingest: true,
213            include_sidechain: true,
214        }
215    }
216
217    fn fixture(index_text: bool) -> (tempfile::TempDir, StorePaths) {
218        store(
219            &[
220                ("a", ms(2026, 8, 1, 9), "acme-api", RUN_TESTS),
221                ("b", ms(2026, 8, 2, 9), "acme-api", RUN_TESTS),
222                ("c", ms(2026, 8, 3, 9), "acme-api", "a one-off"),
223            ],
224            index_text,
225        )
226    }
227
228    fn detection(index_text: bool) -> Detection {
229        let (_dir, paths) = fixture(index_text);
230        suggest::detect(&Scanner::new(paths), TimeWindow::all(), None).unwrap()
231    }
232
233    #[test]
234    fn lists_repeated_prompts_as_a_table_and_as_json() {
235        let (_dir, paths) = fixture(true);
236        let config = Config::default();
237        let Outcome::Listed(report) = run(&env(&paths, &config), None).unwrap() else {
238            panic!("expected a listing");
239        };
240        assert_eq!(report.name, "suggest");
241        assert_eq!(report.json_rows.len(), 1);
242        assert_eq!(report.json_rows[0]["count"], 2);
243        assert_eq!(report.json_rows[0]["text"], RUN_TESTS);
244        assert_eq!(report.json_rows[0]["suggestion"]["kind"], "slash-command");
245
246        let rendered = report.table.render(Style::plain());
247        assert!(rendered.starts_with("ID"), "{rendered}");
248        assert!(rendered.contains("save as a slash command"), "{rendered}");
249        assert!(rendered.contains("acme-api"), "{rendered}");
250
251        let envelope = serde_json::to_value(report.envelope()).unwrap();
252        assert_eq!(envelope["report"], "suggest");
253        assert!(envelope["rows"].is_array());
254    }
255
256    #[test]
257    fn json_rows_say_when_text_was_never_stored() {
258        let report = build(&detection(false), TimeWindow::all(), ms(2026, 8, 4, 9));
259        assert!(report.json_rows[0]["text"].is_null());
260        assert_eq!(report.json_rows[0]["text_indexed"], false);
261        assert!(report.notes.iter().any(|n| n.contains("index_prompt_text")));
262        assert!(report
263            .table
264            .render(Style::plain())
265            .contains("(text not indexed)"));
266    }
267
268    #[test]
269    fn a_draft_is_addressed_by_the_short_id() {
270        let groups = detection(true).groups;
271        let (_dir, paths) = fixture(true);
272        let config = Config::default();
273        let Outcome::Drafted(text) = run(&env(&paths, &config), Some(&groups[0].id)).unwrap()
274        else {
275            panic!("expected a draft");
276        };
277        assert!(text.starts_with("---\n"), "{text}");
278        assert!(text.contains(RUN_TESTS), "{text}");
279    }
280
281    #[test]
282    fn a_draft_writes_no_files() {
283        let (dir, paths) = fixture(true);
284        let before = snapshot(dir.path());
285        let groups = suggest::detect(&Scanner::new(paths.clone()), TimeWindow::all(), None)
286            .unwrap()
287            .groups;
288        let config = Config::default();
289        run(&env(&paths, &config), Some(&groups[0].id)).unwrap();
290        assert_eq!(snapshot(dir.path()), before, "--draft must not touch disk");
291    }
292
293    #[test]
294    fn an_unknown_draft_id_errors_helpfully() {
295        let (_dir, paths) = fixture(true);
296        let config = Config::default();
297        let err = run(&env(&paths, &config), Some("deadbeef")).unwrap_err();
298        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
299        assert!(err.to_string().contains("known ids"), "{err}");
300    }
301
302    #[test]
303    fn a_full_hash_and_an_unambiguous_prefix_both_resolve() {
304        let groups = detection(true).groups;
305        assert_eq!(
306            find(&groups, &groups[0].text_hash).unwrap().id,
307            groups[0].id
308        );
309        assert_eq!(find(&groups, &groups[0].id[..4]).unwrap().id, groups[0].id);
310    }
311
312    #[test]
313    fn the_headline_is_not_part_of_the_reports_stdout_rendering() {
314        let report = build(&detection(true), TimeWindow::all(), ms(2026, 8, 4, 9));
315
316        let mut stdout_buf = Vec::new();
317        crate::output::write_report(&mut stdout_buf, &report, false, Style::plain()).unwrap();
318        let stdout_out = String::from_utf8(stdout_buf).unwrap();
319        assert!(
320            !stdout_out.contains("repeated prompt"),
321            "headline must not be on stdout: {stdout_out}"
322        );
323    }
324
325    #[test]
326    fn the_headline_counts_and_pluralises() {
327        let render = |n| {
328            let mut buf = Vec::new();
329            headline(&mut buf, n).unwrap();
330            String::from_utf8(buf).unwrap()
331        };
332        assert!(render(0).starts_with("no repeated prompts found"));
333        assert!(render(1).starts_with("1 repeated prompt found"));
334        assert!(render(3).starts_with("3 repeated prompts found"));
335    }
336
337    /// Every path under `root`, with its size — enough to catch a stray write.
338    fn snapshot(root: &std::path::Path) -> Vec<(std::path::PathBuf, u64)> {
339        let mut out = Vec::new();
340        let mut stack = vec![root.to_path_buf()];
341        while let Some(dir) = stack.pop() {
342            for entry in std::fs::read_dir(&dir).unwrap() {
343                let entry = entry.unwrap();
344                let meta = entry.metadata().unwrap();
345                if meta.is_dir() {
346                    stack.push(entry.path());
347                }
348                out.push((entry.path(), meta.len()));
349            }
350        }
351        out.sort();
352        out
353    }
354}