agentstatedeveloper_cli/commands/
repair.rs1use 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 #[arg(long)]
38 pub fix: bool,
39
40 #[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 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 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 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 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
109fn 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}