memstead_cli/commands/
recover.rs1use clap::Parser;
17
18use memstead_base::vcs::Actor;
19
20use crate::CliError;
21use crate::output::{print_json, print_markdown};
22use crate::setup::CliContext;
23
24#[derive(Parser, Debug)]
28pub struct Args {
29 #[arg(long)]
32 pub note: Option<String>,
33}
34
35pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
36 let mut engine = crate::setup::pro_engine(ctx)?;
37 let note_ref = args.note.as_deref();
38 let report = engine
39 .apply_parse_recovery(Actor::Cli, None, note_ref)
40 .map_err(CliError::from_engine_op)?;
41
42 let (removed, skipped, failed) = recovery_counts(&report);
43
44 if ctx.json {
45 print_json(&recovery_json_envelope(&report))?;
46 return Ok(());
47 }
48
49 let mut lines = vec![
50 format!(
51 "# Parse-recovery — {} removed, {} skipped, {} failed",
52 removed, skipped, failed
53 ),
54 String::new(),
55 ];
56 if report.entries.is_empty() {
57 lines.push("_(workspace already clean — no parse-time drops)_".into());
58 } else {
59 for entry in &report.entries {
60 let marker = match entry.outcome.as_str() {
61 "removed" => "✓",
62 "skipped" => "·",
63 _ => "✗",
64 };
65 let reason = entry
66 .reason
67 .as_ref()
68 .map(|r| format!(" — {r}"))
69 .unwrap_or_default();
70 lines.push(format!(
71 "- {marker} `{}` `{}` → `{}` ({}){}",
72 entry.entity_id, entry.rel_type, entry.target, entry.outcome, reason,
73 ));
74 }
75 if !report.commit_sha.is_empty() {
76 lines.push(String::new());
77 lines.push(format!("Last commit: `{}`", report.commit_sha));
78 }
79 }
80 print_markdown(&lines.join("\n"));
81
82 Ok(())
87}
88
89fn recovery_counts(report: &memstead_base::ops::ParseRecoveryReport) -> (usize, usize, usize) {
93 let removed = report
94 .entries
95 .iter()
96 .filter(|e| e.outcome == "removed")
97 .count();
98 let skipped = report
99 .entries
100 .iter()
101 .filter(|e| e.outcome == "skipped")
102 .count();
103 let failed = report
104 .entries
105 .iter()
106 .filter(|e| e.outcome == "failed")
107 .count();
108 (removed, skipped, failed)
109}
110
111fn recovery_json_envelope(report: &memstead_base::ops::ParseRecoveryReport) -> serde_json::Value {
119 let (removed, skipped, failed) = recovery_counts(report);
120 let mut obj = serde_json::json!({
121 "removed": removed,
122 "skipped": skipped,
123 "failed": failed,
124 "entries": report.entries,
125 });
126 if !report.commit_sha.is_empty() {
127 obj["commit_sha"] = serde_json::json!(report.commit_sha);
128 }
129 obj
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn recovery_json_envelope_clean_workspace_carries_zero_counters_and_empty_entries() {
138 let report = memstead_base::ops::ParseRecoveryReport::default();
142 let json = recovery_json_envelope(&report);
143 assert_eq!(json["removed"], 0);
144 assert_eq!(json["skipped"], 0);
145 assert_eq!(json["failed"], 0);
146 assert_eq!(json["entries"], serde_json::json!([]));
147 assert!(json.get("commit_sha").is_none());
150 assert_ne!(json, serde_json::json!({}), "must not be the empty object");
151 }
152}