Skip to main content

agentstatedeveloper_cli/commands/
feedback.rs

1//! `asd feedback` — record and list search-quality verdicts.
2//!
3//! Subcommands:
4//!   mark    — attach a verdict (useful/noisy/missing/wrong_layer) to a (query, symbol) pair
5//!   list    — display all recorded verdicts, optionally filtered to one symbol
6//!   expire   — lapse a verdict so it stops influencing search ranking
7//!   withdraw — retract a verdict that should not have been recorded
8//!   purge    — hard-delete a verdict from both stores (escape hatch)
9
10use anyhow::{Result, bail};
11use clap::{Args, Subcommand};
12use uuid::Uuid;
13
14use agentstatedeveloper_core::{
15    AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, FeedbackEntry,
16    FeedbackStore, FeedbackVerdict, IndexStore, LedgerEntry, LedgerKind, LedgerStore, RoleTag,
17};
18
19use crate::config::Config;
20
21#[derive(Debug, Subcommand)]
22pub enum FeedbackCmd {
23    /// Record a verdict for a (query, symbol) result.
24    Mark(MarkArgs),
25    /// List recorded feedback verdicts.
26    List(ListArgs),
27    /// Lapse a verdict so it stops influencing search ranking.
28    Expire(ExpireArgs),
29    /// Retract a verdict that should never have been recorded.
30    Withdraw(WithdrawArgs),
31    /// Hard-delete a verdict from both stores. Prefer `withdraw`.
32    Purge(PurgeArgs),
33    /// Designate a symbol as the canonical source-of-truth for a domain concept.
34    PromoteAsTruth(PromoteAsTruthArgs),
35    /// Export all feedback entries to a JSON file (or stdout).
36    Export(ExportArgs),
37    /// Import feedback entries from a JSON file (or stdin).
38    Import(ImportArgs),
39}
40
41#[derive(Debug, Args)]
42pub struct MarkArgs {
43    /// The search query that produced this result.
44    pub query: String,
45    /// Fully-qualified symbol name being rated. Omit (use "") when --file-scope is set.
46    pub qname: String,
47    /// Verdict: useful, noisy, missing, or wrong_layer.
48    pub verdict: String,
49    /// Optional free-text note.
50    #[arg(long)]
51    pub note: Option<String>,
52    /// Author identifier recorded with the verdict.
53    #[arg(long, default_value = "asd-cli")]
54    pub author: String,
55    /// Apply verdict to all symbols in files matching this glob (e.g. "src/adapters/*.rs").
56    #[arg(long)]
57    pub file_scope: Option<String>,
58    /// Plan E t-009: when --verdict already_covered, the qname of the
59    /// symbol whose behavior covers this one. Auto-writes a paired
60    /// Mapping ledger entry (kind=mapping, body={from, to}) so the
61    /// coverage link is durable, not just a per-query verdict.
62    #[arg(long)]
63    pub covered_by: Option<String>,
64
65    /// Plan J t-014: optional verdict expiry in days from now. After
66    /// `now + N days` the entry no longer influences ranking (still
67    /// visible via `asd feedback list` / `export`). Useful for
68    /// false-positive marks that should auto-decay — code shifts,
69    /// what was wrong last quarter may be right next. Omit to keep
70    /// the verdict permanent (current default).
71    #[arg(long)]
72    pub ttl_days: Option<i64>,
73}
74
75#[derive(Debug, Args)]
76pub struct ListArgs {
77    /// Filter to a specific symbol qname. Omit to list all.
78    pub qname: Option<String>,
79    /// Emit JSON output.
80    #[arg(long)]
81    pub json: bool,
82}
83
84/// Selectors are mutually exclusive and one is required — expiring
85/// everything because a flag was forgotten is not a recoverable mistake.
86#[derive(Debug, Args)]
87pub struct ExpireArgs {
88    /// Entry id to expire (from `asd feedback list`).
89    pub entry_id: Option<String>,
90
91    /// Expire every verdict recorded against this symbol qname.
92    #[arg(long, conflicts_with = "entry_id")]
93    pub symbol: Option<String>,
94
95    /// Expire every verdict recorded for this query (matched as the stored
96    /// normalized form: lowercased and trimmed).
97    #[arg(long, conflicts_with_all = ["entry_id", "symbol"])]
98    pub query: Option<String>,
99
100    /// Show what would be expired without writing.
101    #[arg(long)]
102    pub dry_run: bool,
103
104    /// Emit machine-readable JSON.
105    #[arg(long)]
106    pub json: bool,
107}
108
109#[derive(Debug, Args)]
110pub struct WithdrawArgs {
111    /// Entry id to withdraw (from `asd feedback list`).
112    pub entry_id: String,
113
114    /// Who is retracting it. Recorded on the entry.
115    #[arg(long, default_value = "asd-cli")]
116    pub by: String,
117
118    /// Why. Free text, stored with the withdrawal.
119    #[arg(long)]
120    pub reason: Option<String>,
121
122    /// Emit machine-readable JSON.
123    #[arg(long)]
124    pub json: bool,
125}
126
127#[derive(Debug, Args)]
128pub struct PurgeArgs {
129    /// Entry id to delete permanently (from `asd feedback list`).
130    pub entry_id: String,
131
132    /// Required. Without it the command explains and refuses.
133    #[arg(long)]
134    pub yes: bool,
135
136    /// Emit machine-readable JSON.
137    #[arg(long)]
138    pub json: bool,
139}
140
141#[derive(Debug, Args)]
142pub struct PromoteAsTruthArgs {
143    /// Fully-qualified symbol name to promote.
144    pub qname: String,
145    /// The domain concept this symbol is the source-of-truth for.
146    #[arg(long)]
147    pub concept: String,
148    /// Author identifier recorded with the ownership entry.
149    #[arg(long, default_value = "asd-cli")]
150    pub author: String,
151}
152
153#[derive(Debug, Args)]
154pub struct ExportArgs {
155    /// Output file path. Omit to write to stdout.
156    #[arg(long)]
157    pub output: Option<String>,
158    /// Emit a JSON summary to stdout instead of the full entry list.
159    #[arg(long)]
160    pub summary: bool,
161}
162
163#[derive(Debug, Args)]
164pub struct ImportArgs {
165    /// Input file path. Omit to read from stdin.
166    #[arg(long)]
167    pub input: Option<String>,
168    /// Skip entries whose entry_id already exists in the store.
169    #[arg(long, default_value = "true")]
170    pub skip_existing: bool,
171    /// Author recorded for imported entries.
172    #[arg(long, default_value = "asd-import")]
173    pub author: String,
174    /// Show what would be imported without writing to the store.
175    #[arg(long)]
176    pub dry_run: bool,
177}
178
179pub fn run(cfg: &Config, cmd: FeedbackCmd) -> Result<()> {
180    match cmd {
181        FeedbackCmd::Mark(args) => run_mark(cfg, args),
182        FeedbackCmd::List(args) => run_list(cfg, args),
183        FeedbackCmd::Expire(args) => run_expire(cfg, args),
184        FeedbackCmd::Withdraw(args) => run_withdraw(cfg, args),
185        FeedbackCmd::Purge(args) => run_purge(cfg, args),
186        FeedbackCmd::PromoteAsTruth(args) => run_promote_as_truth(cfg, args),
187        FeedbackCmd::Export(args) => run_export(cfg, args),
188        FeedbackCmd::Import(args) => run_import(cfg, args),
189    }
190}
191
192fn parse_verdict(s: &str) -> Result<FeedbackVerdict> {
193    // Plan C t-005: delegate to FeedbackVerdict::from_str so the verdict
194    // taxonomy stays single-sourced in core. Forgiving on kebab/snake.
195    if let Some(v) = FeedbackVerdict::from_str(s) {
196        return Ok(v);
197    }
198    bail!(
199        "unknown verdict {:?}; valid: useful, noisy, missing, wrong_layer, already_covered, diagnostic_only",
200        s
201    )
202}
203
204fn run_mark(cfg: &Config, args: MarkArgs) -> Result<()> {
205    let verdict = parse_verdict(&args.verdict)?;
206    let engine = Engine::open_sqlite(&cfg.db_path)?;
207    let (symbol_id, symbol_qname) = if let Some(ref glob) = args.file_scope {
208        // File-scoped verdict: no specific symbol required.
209        (
210            format!("__file_scope__{}", Uuid::new_v4().simple()),
211            glob.clone(),
212        )
213    } else {
214        let index_store = AsgIndexStore::from_engine(&engine);
215        let symbol = match index_store.get_symbol_by_qname(&engine.ref_name, &args.qname)? {
216            Some(s) => s,
217            None => bail!("symbol not found: {}", args.qname),
218        };
219        (symbol.symbol_id, args.qname.clone())
220    };
221    let now = chrono::Utc::now();
222    let expires_at = args.ttl_days.map(|days| now + chrono::Duration::days(days));
223    let entry = FeedbackEntry {
224        entry_id: format!("fb_{}", Uuid::new_v4().simple()),
225        symbol_id,
226        symbol_qname: symbol_qname.clone(),
227        query: args.query.to_lowercase().trim().to_string(),
228        verdict,
229        note: args.note.clone(),
230        author: args.author.clone(),
231        created_at: now,
232        file_scope: args.file_scope.clone(),
233        expires_at,
234        // A freshly recorded verdict is never withdrawn.
235        withdrawn_at: None,
236        withdrawn_by: None,
237        withdrawn_reason: None,
238    };
239    let feedback_store = AsgFeedbackStore::from_engine(&engine);
240    feedback_store.record(&engine.ref_name, &entry, &args.author)?;
241
242    // Plan E t-009: auto-write paired ledger entries that make the
243    // verdict's intent durable. AlreadyCovered + --covered-by → a
244    // Mapping entry; DiagnosticOnly → a Classification (Ownership with
245    // role=diagnostic-test). Skipped for file-scope verdicts (no
246    // concrete symbol to anchor the ledger entry on).
247    let mut paired_msg = String::new();
248    if args.file_scope.is_none() {
249        let author_kind = if args.author == "asd-cli" {
250            AuthorKind::Human
251        } else {
252            AuthorKind::Agent
253        };
254        let author_struct = Author {
255            kind: author_kind,
256            id: args.author.clone(),
257        };
258        let ledger_store = AsgLedgerStore::from_engine(&engine);
259
260        if matches!(verdict, FeedbackVerdict::AlreadyCovered) {
261            let cover = args.covered_by.as_deref().ok_or_else(|| {
262                anyhow::anyhow!("--covered-by <qname> is required when --verdict already_covered")
263            })?;
264            let body = serde_json::json!({
265                "from_qname": &args.qname,
266                "to_qname": cover,
267                "source": "feedback-pair",
268            })
269            .to_string();
270            let mut led = LedgerEntry::new(
271                &entry.symbol_id,
272                LedgerKind::Mapping,
273                format!("covered by {cover}"),
274                author_struct.clone(),
275            );
276            led.body = Some(body);
277            led.tags.push("plan-e:t-009".into());
278            ledger_store.append_entry(&engine.ref_name, &led, &args.author)?;
279            paired_msg = format!(" + Mapping ledger entry → {cover}");
280        } else if matches!(verdict, FeedbackVerdict::DiagnosticOnly) {
281            let mut led = LedgerEntry::new(
282                &entry.symbol_id,
283                LedgerKind::Ownership,
284                format!("diagnostic-only: {}", args.query),
285                author_struct.clone(),
286            );
287            led.role = Some(RoleTag::DiagnosticTest.as_str().to_string());
288            led.tags.push("plan-e:t-009".into());
289            ledger_store.append_entry(&engine.ref_name, &led, &args.author)?;
290            paired_msg = " + Classification ledger entry (role=diagnostic-test)".to_string();
291        }
292    }
293
294    if args.file_scope.is_some() {
295        println!(
296            "recorded {} for files matching {:?} ({})",
297            args.verdict, symbol_qname, entry.entry_id
298        );
299    } else {
300        println!(
301            "recorded {} for {} ({}){}",
302            args.verdict, args.qname, entry.entry_id, paired_msg
303        );
304    }
305    Ok(())
306}
307
308fn run_promote_as_truth(cfg: &Config, args: PromoteAsTruthArgs) -> Result<()> {
309    let engine = Engine::open_sqlite(&cfg.db_path)?;
310    let index_store = AsgIndexStore::from_engine(&engine);
311    let symbol = match index_store.get_symbol_by_qname(&engine.ref_name, &args.qname)? {
312        Some(s) => s,
313        None => bail!("symbol not found: {}", args.qname),
314    };
315    let author_kind = if args.author == "asd-cli" {
316        AuthorKind::Human
317    } else {
318        AuthorKind::Agent
319    };
320    let mut entry = LedgerEntry::new(
321        &symbol.symbol_id,
322        LedgerKind::Ownership,
323        &args.concept,
324        Author {
325            kind: author_kind,
326            id: args.author.clone(),
327        },
328    );
329    entry.tags = vec!["promote-as-truth".to_string()];
330    let ledger_store = AsgLedgerStore::from_engine(&engine);
331    ledger_store.append_entry(&engine.ref_name, &entry, &args.author)?;
332    println!(
333        "promoted {} as source-of-truth for \"{}\" ({})",
334        args.qname, args.concept, entry.entry_id
335    );
336    Ok(())
337}
338
339fn run_list(cfg: &Config, args: ListArgs) -> Result<()> {
340    let engine = Engine::open_sqlite(&cfg.db_path)?;
341    let feedback_store = AsgFeedbackStore::from_engine(&engine);
342    let entries = if let Some(ref qname) = args.qname {
343        let index_store = AsgIndexStore::from_engine(&engine);
344        match index_store.get_symbol_by_qname(&engine.ref_name, qname)? {
345            Some(sym) => feedback_store.list_for_symbol(&engine.ref_name, &sym.symbol_id)?,
346            None => bail!("symbol not found: {}", qname),
347        }
348    } else {
349        feedback_store.list_all(&engine.ref_name)?
350    };
351
352    if args.json {
353        println!("{}", serde_json::to_string_pretty(&entries)?);
354        return Ok(());
355    }
356
357    if entries.is_empty() {
358        println!("no feedback recorded");
359        return Ok(());
360    }
361
362    for e in &entries {
363        println!(
364            "[{}] {:?}  {}  query={:?}  author={}",
365            e.entry_id, e.verdict, e.symbol_qname, e.query, e.author
366        );
367        if let Some(ref note) = e.note {
368            println!("    note: {}", note);
369        }
370    }
371    Ok(())
372}
373
374fn verdict_breakdown(entries: &[FeedbackEntry]) -> (usize, usize, usize, usize) {
375    let useful = entries
376        .iter()
377        .filter(|e| matches!(e.verdict, FeedbackVerdict::Useful))
378        .count();
379    let noisy = entries
380        .iter()
381        .filter(|e| matches!(e.verdict, FeedbackVerdict::Noisy))
382        .count();
383    let missing = entries
384        .iter()
385        .filter(|e| matches!(e.verdict, FeedbackVerdict::Missing))
386        .count();
387    let wl = entries
388        .iter()
389        .filter(|e| matches!(e.verdict, FeedbackVerdict::WrongLayer))
390        .count();
391    (useful, noisy, missing, wl)
392}
393
394fn run_export(cfg: &Config, args: ExportArgs) -> Result<()> {
395    let engine = Engine::open_sqlite(&cfg.db_path)?;
396    let feedback_store = AsgFeedbackStore::from_engine(&engine);
397    let entries = feedback_store.list_all(&engine.ref_name)?;
398
399    if args.summary {
400        let (useful, noisy, missing, wl) = verdict_breakdown(&entries);
401        println!(
402            "{}",
403            serde_json::to_string_pretty(&serde_json::json!({
404                "total": entries.len(),
405                "by_verdict": {
406                    "useful": useful,
407                    "noisy": noisy,
408                    "missing": missing,
409                    "wrong_layer": wl,
410                },
411                "db": cfg.db_path.display().to_string(),
412            }))?
413        );
414        return Ok(());
415    }
416
417    let json = serde_json::to_string_pretty(&entries)?;
418    let (useful, noisy, missing, wl) = verdict_breakdown(&entries);
419    match args.output {
420        Some(ref path) => {
421            std::fs::write(path, &json)?;
422            eprintln!(
423                "asd: exported {} feedback entries to {} (useful={}, noisy={}, missing={}, wrong_layer={})",
424                entries.len(),
425                path,
426                useful,
427                noisy,
428                missing,
429                wl
430            );
431        }
432        None => println!("{}", json),
433    }
434    Ok(())
435}
436
437fn run_import(cfg: &Config, args: ImportArgs) -> Result<()> {
438    use agentstatedeveloper_core::schema::FeedbackEntry;
439    let raw = match args.input {
440        Some(ref path) => std::fs::read_to_string(path)?,
441        None => {
442            use std::io::Read;
443            let mut buf = String::new();
444            std::io::stdin().read_to_string(&mut buf)?;
445            buf
446        }
447    };
448    let incoming: Vec<FeedbackEntry> = serde_json::from_str(&raw)
449        .map_err(|e| anyhow::anyhow!("failed to parse feedback JSON: {e}"))?;
450
451    let engine = Engine::open_sqlite(&cfg.db_path)?;
452    let feedback_store = AsgFeedbackStore::from_engine(&engine);
453
454    let existing_ids: std::collections::HashSet<String> = if args.skip_existing || args.dry_run {
455        feedback_store
456            .list_all(&engine.ref_name)?
457            .into_iter()
458            .map(|e| e.entry_id)
459            .collect()
460    } else {
461        std::collections::HashSet::new()
462    };
463
464    let mut to_import: Vec<&FeedbackEntry> = Vec::new();
465    let mut skipped = 0usize;
466    for entry in &incoming {
467        if (args.skip_existing || args.dry_run) && existing_ids.contains(&entry.entry_id) {
468            skipped += 1;
469            continue;
470        }
471        to_import.push(entry);
472    }
473
474    let (useful, noisy, missing, wl) = {
475        let u = to_import
476            .iter()
477            .filter(|e| matches!(e.verdict, FeedbackVerdict::Useful))
478            .count();
479        let n = to_import
480            .iter()
481            .filter(|e| matches!(e.verdict, FeedbackVerdict::Noisy))
482            .count();
483        let m = to_import
484            .iter()
485            .filter(|e| matches!(e.verdict, FeedbackVerdict::Missing))
486            .count();
487        let w = to_import
488            .iter()
489            .filter(|e| matches!(e.verdict, FeedbackVerdict::WrongLayer))
490            .count();
491        (u, n, m, w)
492    };
493
494    if args.dry_run {
495        eprintln!(
496            "asd: [dry-run] would import {} entries, skip {} duplicates (useful={}, noisy={}, missing={}, wrong_layer={})",
497            to_import.len(),
498            skipped,
499            useful,
500            noisy,
501            missing,
502            wl
503        );
504        return Ok(());
505    }
506
507    for entry in &to_import {
508        feedback_store.record(&engine.ref_name, entry, &args.author)?;
509    }
510    eprintln!(
511        "asd: imported {} entries, skipped {} duplicates (useful={}, noisy={}, missing={}, wrong_layer={})",
512        to_import.len(),
513        skipped,
514        useful,
515        noisy,
516        missing,
517        wl
518    );
519    Ok(())
520}
521
522/// `asd feedback expire` — lapse a verdict so search ranking stops seeing it.
523///
524/// Feedback is not inert metadata: `apply_feedback_adjustments` folds these
525/// verdicts into ranking, so a mistaken `noisy` suppresses a good symbol on
526/// every future query. Until this existed there was no way to take one back —
527/// feedback was the only write-only surface in ASD, while ledger has
528/// withdraw/supersede and scratch has discard.
529///
530/// Implemented as a re-record with `expires_at = now` rather than a delete.
531/// That reuses the expiry field and the lapsed-entry filtering Plan J t-014
532/// already added, and — the part that matters — it goes through
533/// `FeedbackStore::record`, which writes the authoritative ASG tree AND the
534/// `asd_feedback` SQLite cache that `list_all` reads first. A bespoke delete
535/// touching only one of those leaves the entry live in the other; that trap
536/// is what motivated this command.
537///
538/// The entry stays listed, marked expired. A retracted verdict still explains
539/// why a past search ranked the way it did, so hiding it would make old
540/// results inexplicable.
541fn run_expire(cfg: &Config, args: ExpireArgs) -> Result<()> {
542    let engine = Engine::open_sqlite(&cfg.db_path)?;
543    let feedback_store = AsgFeedbackStore::from_engine(&engine);
544    let now = chrono::Utc::now();
545
546    let all = feedback_store.list_all(&engine.ref_name)?;
547
548    // Match the stored normalization so `--query "Drift Playhead"` finds the
549    // entry recorded as `drift playhead`.
550    let wanted_query = args
551        .query
552        .as_ref()
553        .map(|q| q.to_lowercase().trim().to_string());
554
555    let selected: Vec<&FeedbackEntry> = match (&args.entry_id, &args.symbol, &wanted_query) {
556        (Some(id), _, _) => all.iter().filter(|e| &e.entry_id == id).collect(),
557        (_, Some(qname), _) => all.iter().filter(|e| &e.symbol_qname == qname).collect(),
558        (_, _, Some(q)) => all.iter().filter(|e| &e.query == q).collect(),
559        _ => bail!("give an entry id, or one of --symbol <qname> / --query <text>"),
560    };
561
562    if selected.is_empty() {
563        bail!(
564            "no feedback matched; `asd feedback list` shows {} recorded entr{}",
565            all.len(),
566            if all.len() == 1 { "y" } else { "ies" }
567        );
568    }
569
570    // Idempotent: re-expiring an already-lapsed entry would rewrite its
571    // expires_at to a later timestamp, which is the opposite of what the
572    // caller wants.
573    let (already, to_expire): (Vec<_>, Vec<_>) = selected
574        .into_iter()
575        .partition(|e| e.expires_at.is_some_and(|t| t <= now));
576
577    if args.dry_run {
578        let out = serde_json::json!({
579            "dry_run": true,
580            "would_expire": to_expire.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
581            "already_expired": already.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
582        });
583        println!("{}", serde_json::to_string_pretty(&out)?);
584        return Ok(());
585    }
586
587    let mut expired = Vec::new();
588    for entry in &to_expire {
589        let mut lapsed = (*entry).clone();
590        lapsed.expires_at = Some(now);
591        feedback_store.record(&engine.ref_name, &lapsed, &lapsed.author)?;
592        expired.push(summarize(&lapsed));
593    }
594
595    if args.json {
596        let out = serde_json::json!({
597            "expired": expired,
598            "already_expired": already.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
599        });
600        println!("{}", serde_json::to_string_pretty(&out)?);
601        return Ok(());
602    }
603
604    for e in &to_expire {
605        println!(
606            "expired {}  {:?}  {}  query={:?}",
607            e.entry_id, e.verdict, e.symbol_qname, e.query
608        );
609    }
610    for e in &already {
611        println!("already expired {}  ({})", e.entry_id, e.symbol_qname);
612    }
613    if !expired.is_empty() {
614        println!(
615            "\n{} verdict{} will no longer influence ranking. They remain in \
616             `asd feedback list`, marked expired.",
617            expired.len(),
618            if expired.len() == 1 { "" } else { "s" }
619        );
620    }
621    Ok(())
622}
623
624/// Compact identity for an entry in command output.
625fn summarize(e: &FeedbackEntry) -> serde_json::Value {
626    serde_json::json!({
627        "entry_id": e.entry_id,
628        "symbol_qname": e.symbol_qname,
629        "query": e.query,
630        "verdict": format!("{:?}", e.verdict),
631        "expires_at": e.expires_at,
632    })
633}
634
635/// `asd feedback withdraw` — retract a verdict that should not have been
636/// recorded.
637///
638/// Distinct from `expire` on purpose. Expiry says *this was right, it is no
639/// longer relevant*; withdrawal says *this was wrong*. Both stop the verdict
640/// influencing ranking, but only withdrawal records who retracted it and why,
641/// and only withdrawal is not revivable by future-dating an expiry. See
642/// DESIGN.md, "Feedback withdrawal — tombstone shape".
643///
644/// The entry stays in `asd feedback list`, marked withdrawn — it still
645/// explains why a past search ranked as it did.
646fn run_withdraw(cfg: &Config, args: WithdrawArgs) -> Result<()> {
647    let engine = Engine::open_sqlite(&cfg.db_path)?;
648    let store = AsgFeedbackStore::from_engine(&engine);
649
650    let Some(entry) = store.withdraw(
651        &engine.ref_name,
652        &args.entry_id,
653        &args.by,
654        args.reason.as_deref(),
655    )?
656    else {
657        let n = store.list_all(&engine.ref_name)?.len();
658        bail!(
659            "no feedback entry {}; `asd feedback list` shows {} recorded entr{}",
660            args.entry_id,
661            n,
662            if n == 1 { "y" } else { "ies" }
663        );
664    };
665
666    if args.json {
667        println!("{}", serde_json::to_string_pretty(&entry)?);
668        return Ok(());
669    }
670    println!(
671        "withdrawn {}  {:?}  {}  query={:?}",
672        entry.entry_id, entry.verdict, entry.symbol_qname, entry.query
673    );
674    if let Some(ref r) = entry.withdrawn_reason {
675        println!("    reason: {r}");
676    }
677    println!(
678        "\nIt no longer influences ranking. It remains in `asd feedback list`, \
679         marked withdrawn — it still explains a past ranking."
680    );
681    Ok(())
682}
683
684/// `asd feedback purge` — permanently delete a verdict from both stores.
685///
686/// The escape hatch a tombstone cannot serve: test data written by mistake, or
687/// a secret pasted into a `--note`. For anything else `withdraw` is correct —
688/// it retracts the verdict while keeping the record, which still explains why
689/// a past search ranked as it did.
690///
691/// This rewrites history in a store that is otherwise append-only, so it is
692/// gated behind `--yes` and says what it is about to destroy first.
693fn run_purge(cfg: &Config, args: PurgeArgs) -> Result<()> {
694    let engine = Engine::open_sqlite(&cfg.db_path)?;
695    let store = AsgFeedbackStore::from_engine(&engine);
696
697    // Show the entry before destroying it — an id alone is not enough for
698    // someone to confirm they are deleting what they think they are.
699    let Some(target) = store
700        .list_all(&engine.ref_name)?
701        .into_iter()
702        .find(|e| e.entry_id == args.entry_id)
703    else {
704        bail!("no feedback entry {}", args.entry_id);
705    };
706
707    if !args.yes {
708        eprintln!(
709            "about to PERMANENTLY delete:\n  \
710             {}  {:?}  {}  query={:?}",
711            target.entry_id, target.verdict, target.symbol_qname, target.query
712        );
713        if let Some(ref n) = target.note {
714            eprintln!("  note: {n}");
715        }
716        bail!(
717            "refusing without --yes.\n\
718             This rewrites history in an append-only store. If the verdict was \
719             merely wrong, `asd feedback withdraw {}` retracts it while keeping \
720             the record — almost always what you want. Purge is for data that \
721             must not exist: test entries, or a secret pasted into a note.",
722            args.entry_id
723        );
724    }
725
726    let purged = store
727        .purge(&engine.ref_name, &args.entry_id)?
728        .ok_or_else(|| anyhow::anyhow!("no feedback entry {}", args.entry_id))?;
729
730    if args.json {
731        println!("{}", serde_json::json!({ "purged": true, "entry": purged }));
732        return Ok(());
733    }
734    println!(
735        "purged {}  {:?}  {}  query={:?}",
736        purged.entry_id, purged.verdict, purged.symbol_qname, purged.query
737    );
738    println!("Gone from both the store and the search cache.");
739    Ok(())
740}