Skip to main content

memstead_cli/commands/
recover.rs

1//! `memstead recover` — bulk-fix accumulated parse-time drift.
2//!
3//! Walks `engine.load_warnings()` for `PARSED_RELATION_INVALID`
4//! warnings, dispatches the `remove_explicit_relation` recovery on
5//! every writable-origin entry (one re-render per source entity),
6//! reports the read-only-origin entries as skipped. Per-entry
7//! failures keep the rest of the batch alive — the operator inspects
8//! the result for `outcome: "failed"` rows.
9//!
10//! Output:
11//! - JSON (default with `--json` global): the raw
12//!   `ParseRecoveryReport` shape — `{ entries: [...], commit_sha }`.
13//! - Markdown: counts header + one bullet per entry with
14//!   outcome / reason / id.
15
16use 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/// Apply parse-time-drift recovery actions across every writable
25/// mem. Read-only-origin warnings remain (out of scope) and are
26/// reported as skipped.
27#[derive(Parser, Debug)]
28pub struct Args {
29    /// Optional commit-body note recorded on every per-source
30    /// re-render commit the recovery produces.
31    #[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    // Parse-recovery is a best-effort sweep (unlike the atomic
83    // `batch-update`): per-entry failures land on the response and the
84    // surviving entries are still recovered. Scripts that want a
85    // non-zero exit on a partial failure inspect the JSON.
86    Ok(())
87}
88
89/// `(removed, skipped, failed)` outcome counts over the report's
90/// entries — the same three numbers the markdown header and the JSON
91/// envelope both surface.
92fn 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
111/// Build the `memstead recover --json` envelope. Carries the three counters
112/// unconditionally plus the always-present `entries` array, so a clean
113/// workspace returns `{removed:0, skipped:0, failed:0, entries:[]}` —
114/// distinguishable from a serialization failure or an unrecognised
115/// command (the raw report serialises to `{}` when clean because both
116/// its fields skip-serialise when empty). Mirrors the markdown channel's
117/// counter summary. `commit_sha` stays omitted when no recovery wrote.
118fn 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        // A clean workspace yields a default report (no entries, empty
139        // commit_sha). The envelope must be the unambiguous zero-counter
140        // object, not `{}`.
141        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        // `commit_sha` omitted when nothing wrote — the clean shape is
148        // exactly the four documented keys.
149        assert!(json.get("commit_sha").is_none());
150        assert_ne!(json, serde_json::json!({}), "must not be the empty object");
151    }
152}