Skip to main content

agentstatedeveloper_cli/commands/
repair.rs

1//! `asd repair [--fix] [--json]` — scan for ASG integrity issues and optionally
2//! apply safe auto-corrections.
3//!
4//! Without `--fix` the command is read-only (dry-run).  Use `--fix` to
5//! automatically drop orphaned effect records and stale callee/caller refs,
6//! and to restore ledger entries the store lost (from the ledger cache).
7//!
8//! ## Example output (human-readable, dry-run)
9//!
10//! ```text
11//! Scanning ASG for integrity issues…
12//!
13//! WARN  orphaned_effect  /asd/v1/effects/abc123
14//!       symbol 'abc123' not found in index; effect record is orphaned  [auto-fixable]
15//!
16//! WARN  orphaned_callee_ref  /asd/v1/index/callees/def456
17//!       callee 'xyz789' referenced by 'def456' no longer exists  [auto-fixable]
18//!
19//! 2 issues found (2 auto-fixable).  Run `asd repair --fix` to apply corrections.
20//! ```
21
22use anyhow::Result;
23use clap::Args;
24
25use agentstatedeveloper_core::{
26    Engine, IssueSeverity, RepairIssue, missing_ledger_entries, repair_asg_with_cache,
27    restore_missing_ledger_entries, scan_asg, scan_sidecar,
28};
29
30use crate::config::Config;
31
32#[derive(Debug, Args)]
33pub struct RepairArgs {
34    /// Apply auto-fixable corrections (drop orphaned effects and stale
35    /// callee/caller refs; restore lost ledger entries from the ledger
36    /// cache).  Without this flag the command is read-only.
37    #[arg(long)]
38    pub fix: bool,
39
40    /// Emit a JSON object instead of human-readable text.
41    #[arg(long)]
42    pub json: bool,
43}
44
45pub fn run(cfg: &Config, args: RepairArgs) -> Result<()> {
46    let engine = Engine::open_sqlite(&cfg.db_path)?;
47
48    // Plan K t-010: always run the sidecar inclusion-rule lint
49    // alongside the ASG integrity scan. The sidecar issues are
50    // read-only and never auto-fixed (intentional — flagged files
51    // might be legitimate work in progress, deserve human review).
52    let conclusions_dir = cfg
53        .db_path
54        .parent()
55        .map(|p| p.join(".asd").join("conclusions"))
56        .unwrap_or_else(|| std::path::PathBuf::from(".asd/conclusions"));
57    let sidecar_issues = scan_sidecar(&conclusions_dir);
58
59    if args.fix {
60        // Restore lost ledger entries first, so the ASG scan below sees them.
61        let ledger_found = ledger_missing_issue(&engine)?.is_some();
62        let restore = restore_missing_ledger_entries(&engine, &cfg.agent_id)?;
63        let mut report = repair_asg_with_cache(
64            &engine.repo,
65            engine.fts.as_ref(),
66            &engine.ref_name,
67            &cfg.agent_id,
68            false,
69        )?;
70        report.issues.extend(sidecar_issues.clone());
71        report.issues_found += sidecar_issues.len() + usize::from(ledger_found);
72        report.fixes_applied += restore.restored;
73        // Whatever could not be written is still missing.
74        report.issues.extend(ledger_missing_issue(&engine)?);
75        if restore.restored > 0 {
76            eprintln!(
77                "restored {} ledger entr{} into the store — run `asd sync` to write {} to .asd/",
78                restore.restored,
79                if restore.restored == 1 { "y" } else { "ies" },
80                if restore.restored == 1 { "it" } else { "them" }
81            );
82        }
83        if args.json {
84            println!("{}", serde_json::to_string_pretty(&report)?);
85        } else {
86            print_report_human(&report, false);
87        }
88    } else {
89        // Dry-run: scan only.
90        let mut issues = scan_asg(&engine.repo, &engine.ref_name)?;
91        issues.extend(ledger_missing_issue(&engine)?);
92        issues.extend(sidecar_issues);
93        let report = agentstatedeveloper_core::RepairReport {
94            issues_found: issues.len(),
95            fixes_applied: 0,
96            dry_run: true,
97            issues,
98        };
99        if args.json {
100            println!("{}", serde_json::to_string_pretty(&report)?);
101        } else {
102            print_report_human(&report, true);
103        }
104    }
105
106    Ok(())
107}
108
109/// One issue summarising ledger entries the cache acknowledged but the store
110/// has lost — one line, not one per entry, since a single bad day can lose
111/// thousands. `None` when nothing is missing.
112fn ledger_missing_issue(engine: &Engine) -> Result<Option<RepairIssue>> {
113    let (missing, unparseable) = missing_ledger_entries(engine)?;
114    if missing.is_empty() && unparseable.is_empty() {
115        return Ok(None);
116    }
117    let mut by_kind = std::collections::BTreeMap::<&str, usize>::new();
118    for e in &missing {
119        *by_kind.entry(e.kind.as_str()).or_default() += 1;
120    }
121    let kinds = by_kind
122        .iter()
123        .map(|(k, n)| format!("{n} {k}"))
124        .collect::<Vec<_>>()
125        .join(", ");
126    let sample = missing
127        .iter()
128        .take(3)
129        .map(|e| e.entry_id.as_str())
130        .collect::<Vec<_>>()
131        .join(", ");
132    let unparseable_note = if unparseable.is_empty() {
133        String::new()
134    } else {
135        format!(
136            ". {} more cannot be restored: their cached body no longer parses ({})",
137            unparseable.len(),
138            unparseable
139                .iter()
140                .take(3)
141                .cloned()
142                .collect::<Vec<_>>()
143                .join(", ")
144        )
145    };
146    Ok(Some(RepairIssue {
147        kind: "ledger_missing_from_asg".into(),
148        severity: IssueSeverity::Error,
149        path: "/asd/v1/ledger".into(),
150        detail: format!(
151            "{} ledger entr{} acknowledged (in the ledger cache) but missing from the store, \
152             so `asd sync` cannot export {} and `asd hydrate` cannot restore {} ({kinds}; e.g. {sample}). \
153             --fix restores {} from the cache{unparseable_note}",
154            missing.len(),
155            if missing.len() == 1 { "y" } else { "ies" },
156            if missing.len() == 1 { "it" } else { "them" },
157            if missing.len() == 1 { "it" } else { "them" },
158            if missing.len() == 1 { "it" } else { "them" },
159        ),
160        auto_fixable: true,
161    }))
162}
163
164fn print_report_human(report: &agentstatedeveloper_core::RepairReport, dry_run: bool) {
165    if dry_run {
166        eprintln!("Scanning ASG for integrity issues…\n");
167    }
168
169    if report.issues.is_empty() {
170        println!("✓ No integrity issues found.");
171        return;
172    }
173
174    for issue in &report.issues {
175        let sev = match issue.severity {
176            IssueSeverity::Warn => "WARN ",
177            IssueSeverity::Error => "ERROR",
178        };
179        let fixable = if issue.auto_fixable {
180            "  [auto-fixable]"
181        } else {
182            ""
183        };
184        println!("{sev}  {}  {}", issue.kind, issue.path);
185        println!("      {}{}\n", issue.detail, fixable);
186    }
187
188    let auto_fixable = report.issues.iter().filter(|i| i.auto_fixable).count();
189
190    if dry_run {
191        if auto_fixable > 0 {
192            println!(
193                "{} issue(s) found ({} auto-fixable).  Run `asd repair --fix` to apply corrections.",
194                report.issues_found, auto_fixable
195            );
196        } else {
197            println!(
198                "{} issue(s) found (none auto-fixable).  Manual intervention required.",
199                report.issues_found
200            );
201        }
202    } else {
203        println!(
204            "{} issue(s) found before repair.  {} correction(s) applied.  {} issue(s) remain.",
205            report.issues_found,
206            report.fixes_applied,
207            report.issues.len()
208        );
209    }
210}