1use 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
18const TABLE_ROWS: usize = 20;
21
22#[derive(Debug, Clone)]
24pub enum Outcome {
25 Listed(Report),
27 Drafted(String),
29}
30
31pub 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 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
59fn 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
98fn 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 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}