agentstatedeveloper-cli 1.4.9

asd CLI — command-line interface for AgentStateDeveloper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
//! `asd feedback` — record and list search-quality verdicts.
//!
//! Subcommands:
//!   mark    — attach a verdict (useful/noisy/missing/wrong_layer) to a (query, symbol) pair
//!   list    — display all recorded verdicts, optionally filtered to one symbol
//!   expire   — lapse a verdict so it stops influencing search ranking
//!   withdraw — retract a verdict that should not have been recorded
//!   purge    — hard-delete a verdict from both stores (escape hatch)

use anyhow::{Result, bail};
use clap::{Args, Subcommand};
use uuid::Uuid;

use agentstatedeveloper_core::{
    AsgFeedbackStore, AsgIndexStore, AsgLedgerStore, Author, AuthorKind, Engine, FeedbackEntry,
    FeedbackStore, FeedbackVerdict, IndexStore, LedgerEntry, LedgerKind, LedgerStore, RoleTag,
};

use crate::config::Config;

#[derive(Debug, Subcommand)]
pub enum FeedbackCmd {
    /// Record a verdict for a (query, symbol) result.
    Mark(MarkArgs),
    /// List recorded feedback verdicts.
    List(ListArgs),
    /// Lapse a verdict so it stops influencing search ranking.
    Expire(ExpireArgs),
    /// Retract a verdict that should never have been recorded.
    Withdraw(WithdrawArgs),
    /// Hard-delete a verdict from both stores. Prefer `withdraw`.
    Purge(PurgeArgs),
    /// Designate a symbol as the canonical source-of-truth for a domain concept.
    PromoteAsTruth(PromoteAsTruthArgs),
    /// Export all feedback entries to a JSON file (or stdout).
    Export(ExportArgs),
    /// Import feedback entries from a JSON file (or stdin).
    Import(ImportArgs),
}

#[derive(Debug, Args)]
pub struct MarkArgs {
    /// The search query that produced this result.
    pub query: String,
    /// Fully-qualified symbol name being rated. Omit (use "") when --file-scope is set.
    pub qname: String,
    /// Verdict: useful, noisy, missing, or wrong_layer.
    pub verdict: String,
    /// Optional free-text note.
    #[arg(long)]
    pub note: Option<String>,
    /// Author identifier recorded with the verdict.
    #[arg(long, default_value = "asd-cli")]
    pub author: String,
    /// Apply verdict to all symbols in files matching this glob (e.g. "src/adapters/*.rs").
    #[arg(long)]
    pub file_scope: Option<String>,
    /// Plan E t-009: when --verdict already_covered, the qname of the
    /// symbol whose behavior covers this one. Auto-writes a paired
    /// Mapping ledger entry (kind=mapping, body={from, to}) so the
    /// coverage link is durable, not just a per-query verdict.
    #[arg(long)]
    pub covered_by: Option<String>,

    /// Plan J t-014: optional verdict expiry in days from now. After
    /// `now + N days` the entry no longer influences ranking (still
    /// visible via `asd feedback list` / `export`). Useful for
    /// false-positive marks that should auto-decay — code shifts,
    /// what was wrong last quarter may be right next. Omit to keep
    /// the verdict permanent (current default).
    #[arg(long)]
    pub ttl_days: Option<i64>,
}

#[derive(Debug, Args)]
pub struct ListArgs {
    /// Filter to a specific symbol qname. Omit to list all.
    pub qname: Option<String>,
    /// Emit JSON output.
    #[arg(long)]
    pub json: bool,
}

/// Selectors are mutually exclusive and one is required — expiring
/// everything because a flag was forgotten is not a recoverable mistake.
#[derive(Debug, Args)]
pub struct ExpireArgs {
    /// Entry id to expire (from `asd feedback list`).
    pub entry_id: Option<String>,

    /// Expire every verdict recorded against this symbol qname.
    #[arg(long, conflicts_with = "entry_id")]
    pub symbol: Option<String>,

    /// Expire every verdict recorded for this query (matched as the stored
    /// normalized form: lowercased and trimmed).
    #[arg(long, conflicts_with_all = ["entry_id", "symbol"])]
    pub query: Option<String>,

    /// Show what would be expired without writing.
    #[arg(long)]
    pub dry_run: bool,

    /// Emit machine-readable JSON.
    #[arg(long)]
    pub json: bool,
}

#[derive(Debug, Args)]
pub struct WithdrawArgs {
    /// Entry id to withdraw (from `asd feedback list`).
    pub entry_id: String,

    /// Who is retracting it. Recorded on the entry.
    #[arg(long, default_value = "asd-cli")]
    pub by: String,

    /// Why. Free text, stored with the withdrawal.
    #[arg(long)]
    pub reason: Option<String>,

    /// Emit machine-readable JSON.
    #[arg(long)]
    pub json: bool,
}

#[derive(Debug, Args)]
pub struct PurgeArgs {
    /// Entry id to delete permanently (from `asd feedback list`).
    pub entry_id: String,

    /// Required. Without it the command explains and refuses.
    #[arg(long)]
    pub yes: bool,

    /// Emit machine-readable JSON.
    #[arg(long)]
    pub json: bool,
}

#[derive(Debug, Args)]
pub struct PromoteAsTruthArgs {
    /// Fully-qualified symbol name to promote.
    pub qname: String,
    /// The domain concept this symbol is the source-of-truth for.
    #[arg(long)]
    pub concept: String,
    /// Author identifier recorded with the ownership entry.
    #[arg(long, default_value = "asd-cli")]
    pub author: String,
}

#[derive(Debug, Args)]
pub struct ExportArgs {
    /// Output file path. Omit to write to stdout.
    #[arg(long)]
    pub output: Option<String>,
    /// Emit a JSON summary to stdout instead of the full entry list.
    #[arg(long)]
    pub summary: bool,
}

#[derive(Debug, Args)]
pub struct ImportArgs {
    /// Input file path. Omit to read from stdin.
    #[arg(long)]
    pub input: Option<String>,
    /// Skip entries whose entry_id already exists in the store.
    #[arg(long, default_value = "true")]
    pub skip_existing: bool,
    /// Author recorded for imported entries.
    #[arg(long, default_value = "asd-import")]
    pub author: String,
    /// Show what would be imported without writing to the store.
    #[arg(long)]
    pub dry_run: bool,
}

pub fn run(cfg: &Config, cmd: FeedbackCmd) -> Result<()> {
    match cmd {
        FeedbackCmd::Mark(args) => run_mark(cfg, args),
        FeedbackCmd::List(args) => run_list(cfg, args),
        FeedbackCmd::Expire(args) => run_expire(cfg, args),
        FeedbackCmd::Withdraw(args) => run_withdraw(cfg, args),
        FeedbackCmd::Purge(args) => run_purge(cfg, args),
        FeedbackCmd::PromoteAsTruth(args) => run_promote_as_truth(cfg, args),
        FeedbackCmd::Export(args) => run_export(cfg, args),
        FeedbackCmd::Import(args) => run_import(cfg, args),
    }
}

fn parse_verdict(s: &str) -> Result<FeedbackVerdict> {
    // Plan C t-005: delegate to FeedbackVerdict::from_str so the verdict
    // taxonomy stays single-sourced in core. Forgiving on kebab/snake.
    if let Some(v) = FeedbackVerdict::from_str(s) {
        return Ok(v);
    }
    bail!(
        "unknown verdict {:?}; valid: useful, noisy, missing, wrong_layer, already_covered, diagnostic_only",
        s
    )
}

fn run_mark(cfg: &Config, args: MarkArgs) -> Result<()> {
    let verdict = parse_verdict(&args.verdict)?;
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let (symbol_id, symbol_qname) = if let Some(ref glob) = args.file_scope {
        // File-scoped verdict: no specific symbol required.
        (
            format!("__file_scope__{}", Uuid::new_v4().simple()),
            glob.clone(),
        )
    } else {
        let index_store = AsgIndexStore::from_engine(&engine);
        let symbol = match index_store.get_symbol_by_qname(&engine.ref_name, &args.qname)? {
            Some(s) => s,
            None => bail!("symbol not found: {}", args.qname),
        };
        (symbol.symbol_id, args.qname.clone())
    };
    let now = chrono::Utc::now();
    let expires_at = args.ttl_days.map(|days| now + chrono::Duration::days(days));
    let entry = FeedbackEntry {
        entry_id: format!("fb_{}", Uuid::new_v4().simple()),
        symbol_id,
        symbol_qname: symbol_qname.clone(),
        query: args.query.to_lowercase().trim().to_string(),
        verdict,
        note: args.note.clone(),
        author: args.author.clone(),
        created_at: now,
        file_scope: args.file_scope.clone(),
        expires_at,
        // A freshly recorded verdict is never withdrawn.
        withdrawn_at: None,
        withdrawn_by: None,
        withdrawn_reason: None,
    };
    let feedback_store = AsgFeedbackStore::from_engine(&engine);
    feedback_store.record(&engine.ref_name, &entry, &args.author)?;

    // Plan E t-009: auto-write paired ledger entries that make the
    // verdict's intent durable. AlreadyCovered + --covered-by → a
    // Mapping entry; DiagnosticOnly → a Classification (Ownership with
    // role=diagnostic-test). Skipped for file-scope verdicts (no
    // concrete symbol to anchor the ledger entry on).
    let mut paired_msg = String::new();
    if args.file_scope.is_none() {
        let author_kind = if args.author == "asd-cli" {
            AuthorKind::Human
        } else {
            AuthorKind::Agent
        };
        let author_struct = Author {
            kind: author_kind,
            id: args.author.clone(),
        };
        let ledger_store = AsgLedgerStore::from_engine(&engine);

        if matches!(verdict, FeedbackVerdict::AlreadyCovered) {
            let cover = args.covered_by.as_deref().ok_or_else(|| {
                anyhow::anyhow!("--covered-by <qname> is required when --verdict already_covered")
            })?;
            let body = serde_json::json!({
                "from_qname": &args.qname,
                "to_qname": cover,
                "source": "feedback-pair",
            })
            .to_string();
            let mut led = LedgerEntry::new(
                &entry.symbol_id,
                LedgerKind::Mapping,
                format!("covered by {cover}"),
                author_struct.clone(),
            );
            led.body = Some(body);
            led.tags.push("plan-e:t-009".into());
            ledger_store.append_entry(&engine.ref_name, &led, &args.author)?;
            paired_msg = format!(" + Mapping ledger entry → {cover}");
        } else if matches!(verdict, FeedbackVerdict::DiagnosticOnly) {
            let mut led = LedgerEntry::new(
                &entry.symbol_id,
                LedgerKind::Ownership,
                format!("diagnostic-only: {}", args.query),
                author_struct.clone(),
            );
            led.role = Some(RoleTag::DiagnosticTest.as_str().to_string());
            led.tags.push("plan-e:t-009".into());
            ledger_store.append_entry(&engine.ref_name, &led, &args.author)?;
            paired_msg = " + Classification ledger entry (role=diagnostic-test)".to_string();
        }
    }

    if args.file_scope.is_some() {
        println!(
            "recorded {} for files matching {:?} ({})",
            args.verdict, symbol_qname, entry.entry_id
        );
    } else {
        println!(
            "recorded {} for {} ({}){}",
            args.verdict, args.qname, entry.entry_id, paired_msg
        );
    }
    Ok(())
}

fn run_promote_as_truth(cfg: &Config, args: PromoteAsTruthArgs) -> Result<()> {
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let index_store = AsgIndexStore::from_engine(&engine);
    let symbol = match index_store.get_symbol_by_qname(&engine.ref_name, &args.qname)? {
        Some(s) => s,
        None => bail!("symbol not found: {}", args.qname),
    };
    let author_kind = if args.author == "asd-cli" {
        AuthorKind::Human
    } else {
        AuthorKind::Agent
    };
    let mut entry = LedgerEntry::new(
        &symbol.symbol_id,
        LedgerKind::Ownership,
        &args.concept,
        Author {
            kind: author_kind,
            id: args.author.clone(),
        },
    );
    entry.tags = vec!["promote-as-truth".to_string()];
    let ledger_store = AsgLedgerStore::from_engine(&engine);
    ledger_store.append_entry(&engine.ref_name, &entry, &args.author)?;
    println!(
        "promoted {} as source-of-truth for \"{}\" ({})",
        args.qname, args.concept, entry.entry_id
    );
    Ok(())
}

fn run_list(cfg: &Config, args: ListArgs) -> Result<()> {
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let feedback_store = AsgFeedbackStore::from_engine(&engine);
    let entries = if let Some(ref qname) = args.qname {
        let index_store = AsgIndexStore::from_engine(&engine);
        match index_store.get_symbol_by_qname(&engine.ref_name, qname)? {
            Some(sym) => feedback_store.list_for_symbol(&engine.ref_name, &sym.symbol_id)?,
            None => bail!("symbol not found: {}", qname),
        }
    } else {
        feedback_store.list_all(&engine.ref_name)?
    };

    if args.json {
        println!("{}", serde_json::to_string_pretty(&entries)?);
        return Ok(());
    }

    if entries.is_empty() {
        println!("no feedback recorded");
        return Ok(());
    }

    for e in &entries {
        println!(
            "[{}] {:?}  {}  query={:?}  author={}",
            e.entry_id, e.verdict, e.symbol_qname, e.query, e.author
        );
        if let Some(ref note) = e.note {
            println!("    note: {}", note);
        }
    }
    Ok(())
}

fn verdict_breakdown(entries: &[FeedbackEntry]) -> (usize, usize, usize, usize) {
    let useful = entries
        .iter()
        .filter(|e| matches!(e.verdict, FeedbackVerdict::Useful))
        .count();
    let noisy = entries
        .iter()
        .filter(|e| matches!(e.verdict, FeedbackVerdict::Noisy))
        .count();
    let missing = entries
        .iter()
        .filter(|e| matches!(e.verdict, FeedbackVerdict::Missing))
        .count();
    let wl = entries
        .iter()
        .filter(|e| matches!(e.verdict, FeedbackVerdict::WrongLayer))
        .count();
    (useful, noisy, missing, wl)
}

fn run_export(cfg: &Config, args: ExportArgs) -> Result<()> {
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let feedback_store = AsgFeedbackStore::from_engine(&engine);
    let entries = feedback_store.list_all(&engine.ref_name)?;

    if args.summary {
        let (useful, noisy, missing, wl) = verdict_breakdown(&entries);
        println!(
            "{}",
            serde_json::to_string_pretty(&serde_json::json!({
                "total": entries.len(),
                "by_verdict": {
                    "useful": useful,
                    "noisy": noisy,
                    "missing": missing,
                    "wrong_layer": wl,
                },
                "db": cfg.db_path.display().to_string(),
            }))?
        );
        return Ok(());
    }

    let json = serde_json::to_string_pretty(&entries)?;
    let (useful, noisy, missing, wl) = verdict_breakdown(&entries);
    match args.output {
        Some(ref path) => {
            std::fs::write(path, &json)?;
            eprintln!(
                "asd: exported {} feedback entries to {} (useful={}, noisy={}, missing={}, wrong_layer={})",
                entries.len(),
                path,
                useful,
                noisy,
                missing,
                wl
            );
        }
        None => println!("{}", json),
    }
    Ok(())
}

fn run_import(cfg: &Config, args: ImportArgs) -> Result<()> {
    use agentstatedeveloper_core::schema::FeedbackEntry;
    let raw = match args.input {
        Some(ref path) => std::fs::read_to_string(path)?,
        None => {
            use std::io::Read;
            let mut buf = String::new();
            std::io::stdin().read_to_string(&mut buf)?;
            buf
        }
    };
    let incoming: Vec<FeedbackEntry> = serde_json::from_str(&raw)
        .map_err(|e| anyhow::anyhow!("failed to parse feedback JSON: {e}"))?;

    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let feedback_store = AsgFeedbackStore::from_engine(&engine);

    let existing_ids: std::collections::HashSet<String> = if args.skip_existing || args.dry_run {
        feedback_store
            .list_all(&engine.ref_name)?
            .into_iter()
            .map(|e| e.entry_id)
            .collect()
    } else {
        std::collections::HashSet::new()
    };

    let mut to_import: Vec<&FeedbackEntry> = Vec::new();
    let mut skipped = 0usize;
    for entry in &incoming {
        if (args.skip_existing || args.dry_run) && existing_ids.contains(&entry.entry_id) {
            skipped += 1;
            continue;
        }
        to_import.push(entry);
    }

    let (useful, noisy, missing, wl) = {
        let u = to_import
            .iter()
            .filter(|e| matches!(e.verdict, FeedbackVerdict::Useful))
            .count();
        let n = to_import
            .iter()
            .filter(|e| matches!(e.verdict, FeedbackVerdict::Noisy))
            .count();
        let m = to_import
            .iter()
            .filter(|e| matches!(e.verdict, FeedbackVerdict::Missing))
            .count();
        let w = to_import
            .iter()
            .filter(|e| matches!(e.verdict, FeedbackVerdict::WrongLayer))
            .count();
        (u, n, m, w)
    };

    if args.dry_run {
        eprintln!(
            "asd: [dry-run] would import {} entries, skip {} duplicates (useful={}, noisy={}, missing={}, wrong_layer={})",
            to_import.len(),
            skipped,
            useful,
            noisy,
            missing,
            wl
        );
        return Ok(());
    }

    for entry in &to_import {
        feedback_store.record(&engine.ref_name, entry, &args.author)?;
    }
    eprintln!(
        "asd: imported {} entries, skipped {} duplicates (useful={}, noisy={}, missing={}, wrong_layer={})",
        to_import.len(),
        skipped,
        useful,
        noisy,
        missing,
        wl
    );
    Ok(())
}

/// `asd feedback expire` — lapse a verdict so search ranking stops seeing it.
///
/// Feedback is not inert metadata: `apply_feedback_adjustments` folds these
/// verdicts into ranking, so a mistaken `noisy` suppresses a good symbol on
/// every future query. Until this existed there was no way to take one back —
/// feedback was the only write-only surface in ASD, while ledger has
/// withdraw/supersede and scratch has discard.
///
/// Implemented as a re-record with `expires_at = now` rather than a delete.
/// That reuses the expiry field and the lapsed-entry filtering Plan J t-014
/// already added, and — the part that matters — it goes through
/// `FeedbackStore::record`, which writes the authoritative ASG tree AND the
/// `asd_feedback` SQLite cache that `list_all` reads first. A bespoke delete
/// touching only one of those leaves the entry live in the other; that trap
/// is what motivated this command.
///
/// The entry stays listed, marked expired. A retracted verdict still explains
/// why a past search ranked the way it did, so hiding it would make old
/// results inexplicable.
fn run_expire(cfg: &Config, args: ExpireArgs) -> Result<()> {
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let feedback_store = AsgFeedbackStore::from_engine(&engine);
    let now = chrono::Utc::now();

    let all = feedback_store.list_all(&engine.ref_name)?;

    // Match the stored normalization so `--query "Drift Playhead"` finds the
    // entry recorded as `drift playhead`.
    let wanted_query = args
        .query
        .as_ref()
        .map(|q| q.to_lowercase().trim().to_string());

    let selected: Vec<&FeedbackEntry> = match (&args.entry_id, &args.symbol, &wanted_query) {
        (Some(id), _, _) => all.iter().filter(|e| &e.entry_id == id).collect(),
        (_, Some(qname), _) => all.iter().filter(|e| &e.symbol_qname == qname).collect(),
        (_, _, Some(q)) => all.iter().filter(|e| &e.query == q).collect(),
        _ => bail!("give an entry id, or one of --symbol <qname> / --query <text>"),
    };

    if selected.is_empty() {
        bail!(
            "no feedback matched; `asd feedback list` shows {} recorded entr{}",
            all.len(),
            if all.len() == 1 { "y" } else { "ies" }
        );
    }

    // Idempotent: re-expiring an already-lapsed entry would rewrite its
    // expires_at to a later timestamp, which is the opposite of what the
    // caller wants.
    let (already, to_expire): (Vec<_>, Vec<_>) = selected
        .into_iter()
        .partition(|e| e.expires_at.is_some_and(|t| t <= now));

    if args.dry_run {
        let out = serde_json::json!({
            "dry_run": true,
            "would_expire": to_expire.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
            "already_expired": already.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
        });
        println!("{}", serde_json::to_string_pretty(&out)?);
        return Ok(());
    }

    let mut expired = Vec::new();
    for entry in &to_expire {
        let mut lapsed = (*entry).clone();
        lapsed.expires_at = Some(now);
        feedback_store.record(&engine.ref_name, &lapsed, &lapsed.author)?;
        expired.push(summarize(&lapsed));
    }

    if args.json {
        let out = serde_json::json!({
            "expired": expired,
            "already_expired": already.iter().map(|e| summarize(e)).collect::<Vec<_>>(),
        });
        println!("{}", serde_json::to_string_pretty(&out)?);
        return Ok(());
    }

    for e in &to_expire {
        println!(
            "expired {}  {:?}  {}  query={:?}",
            e.entry_id, e.verdict, e.symbol_qname, e.query
        );
    }
    for e in &already {
        println!("already expired {}  ({})", e.entry_id, e.symbol_qname);
    }
    if !expired.is_empty() {
        println!(
            "\n{} verdict{} will no longer influence ranking. They remain in \
             `asd feedback list`, marked expired.",
            expired.len(),
            if expired.len() == 1 { "" } else { "s" }
        );
    }
    Ok(())
}

/// Compact identity for an entry in command output.
fn summarize(e: &FeedbackEntry) -> serde_json::Value {
    serde_json::json!({
        "entry_id": e.entry_id,
        "symbol_qname": e.symbol_qname,
        "query": e.query,
        "verdict": format!("{:?}", e.verdict),
        "expires_at": e.expires_at,
    })
}

/// `asd feedback withdraw` — retract a verdict that should not have been
/// recorded.
///
/// Distinct from `expire` on purpose. Expiry says *this was right, it is no
/// longer relevant*; withdrawal says *this was wrong*. Both stop the verdict
/// influencing ranking, but only withdrawal records who retracted it and why,
/// and only withdrawal is not revivable by future-dating an expiry. See
/// DESIGN.md, "Feedback withdrawal — tombstone shape".
///
/// The entry stays in `asd feedback list`, marked withdrawn — it still
/// explains why a past search ranked as it did.
fn run_withdraw(cfg: &Config, args: WithdrawArgs) -> Result<()> {
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let store = AsgFeedbackStore::from_engine(&engine);

    let Some(entry) = store.withdraw(
        &engine.ref_name,
        &args.entry_id,
        &args.by,
        args.reason.as_deref(),
    )?
    else {
        let n = store.list_all(&engine.ref_name)?.len();
        bail!(
            "no feedback entry {}; `asd feedback list` shows {} recorded entr{}",
            args.entry_id,
            n,
            if n == 1 { "y" } else { "ies" }
        );
    };

    if args.json {
        println!("{}", serde_json::to_string_pretty(&entry)?);
        return Ok(());
    }
    println!(
        "withdrawn {}  {:?}  {}  query={:?}",
        entry.entry_id, entry.verdict, entry.symbol_qname, entry.query
    );
    if let Some(ref r) = entry.withdrawn_reason {
        println!("    reason: {r}");
    }
    println!(
        "\nIt no longer influences ranking. It remains in `asd feedback list`, \
         marked withdrawn — it still explains a past ranking."
    );
    Ok(())
}

/// `asd feedback purge` — permanently delete a verdict from both stores.
///
/// The escape hatch a tombstone cannot serve: test data written by mistake, or
/// a secret pasted into a `--note`. For anything else `withdraw` is correct —
/// it retracts the verdict while keeping the record, which still explains why
/// a past search ranked as it did.
///
/// This rewrites history in a store that is otherwise append-only, so it is
/// gated behind `--yes` and says what it is about to destroy first.
fn run_purge(cfg: &Config, args: PurgeArgs) -> Result<()> {
    let engine = Engine::open_sqlite(&cfg.db_path)?;
    let store = AsgFeedbackStore::from_engine(&engine);

    // Show the entry before destroying it — an id alone is not enough for
    // someone to confirm they are deleting what they think they are.
    let Some(target) = store
        .list_all(&engine.ref_name)?
        .into_iter()
        .find(|e| e.entry_id == args.entry_id)
    else {
        bail!("no feedback entry {}", args.entry_id);
    };

    if !args.yes {
        eprintln!(
            "about to PERMANENTLY delete:\n  \
             {}  {:?}  {}  query={:?}",
            target.entry_id, target.verdict, target.symbol_qname, target.query
        );
        if let Some(ref n) = target.note {
            eprintln!("  note: {n}");
        }
        bail!(
            "refusing without --yes.\n\
             This rewrites history in an append-only store. If the verdict was \
             merely wrong, `asd feedback withdraw {}` retracts it while keeping \
             the record — almost always what you want. Purge is for data that \
             must not exist: test entries, or a secret pasted into a note.",
            args.entry_id
        );
    }

    let purged = store
        .purge(&engine.ref_name, &args.entry_id)?
        .ok_or_else(|| anyhow::anyhow!("no feedback entry {}", args.entry_id))?;

    if args.json {
        println!("{}", serde_json::json!({ "purged": true, "entry": purged }));
        return Ok(());
    }
    println!(
        "purged {}  {:?}  {}  query={:?}",
        purged.entry_id, purged.verdict, purged.symbol_qname, purged.query
    );
    println!("Gone from both the store and the search cache.");
    Ok(())
}