Skip to main content

memstead_cli/commands/
batch.rs

1//! Shared plumbing for the batch command family (`batch-update`,
2//! `batch-create`, `batch-relate`): the per-entry markdown breakdown,
3//! the refused-batch error envelope, and the exit-code mapping. One
4//! module so the three commands render and refuse identically — the
5//! family contract is enforced by construction, not by convention.
6
7use crate::CliError;
8use crate::output::ExitKind;
9
10/// Render the per-entry markdown breakdown for a batch result (success
11/// or failure). Each entry shows a status marker, its id/action, and any
12/// per-entry error code+message; an applied batch appends its commit SHA.
13/// `command` is the human-facing command name (`update` / `create` /
14/// `relate`).
15pub(crate) fn render_batch_markdown(
16    command: &str,
17    result: &memstead_base::ops::BatchResult,
18    dry_run: bool,
19) -> String {
20    // A rehearsal must never read as an applied batch: `--dry-run`
21    // validates everything and writes nothing, and the human-facing
22    // markdown has to say so as plainly as the JSON envelope's empty
23    // `commit_sha` does (cold-start 0-8-0, F5).
24    let header = if result.applied && dry_run {
25        format!(
26            "# Batch {command} rehearsed — {} item(s) valid, nothing written",
27            result.succeeded
28        )
29    } else if result.applied {
30        format!(
31            "# Batch {command} applied — {} item(s) in one commit",
32            result.succeeded
33        )
34    } else if dry_run {
35        format!(
36            "# Batch {command} rehearsal REFUSED — {} item(s) failed (nothing would have been written anyway)",
37            result.failed
38        )
39    } else {
40        format!(
41            "# Batch {command} REFUSED — {} item(s) failed, nothing committed",
42            result.failed
43        )
44    };
45    let mut lines = vec![header, String::new()];
46    for entry in &result.results {
47        let marker = if entry.action == "error" {
48            "✗"
49        } else if entry.action == "not_applied" {
50            "·"
51        } else {
52            "✓"
53        };
54        // On a rehearsal, engine actions arrive in the same past tense
55        // as a real run ("created"); render them as conditionals so no
56        // line claims a write that did not happen.
57        let action: std::borrow::Cow<'_, str> = if dry_run {
58            match entry.action.as_str() {
59                "created" => "would create".into(),
60                "updated" => "would update".into(),
61                "related" => "would relate".into(),
62                other => other.into(),
63            }
64        } else {
65            entry.action.as_str().into()
66        };
67        let detail = entry
68            .error
69            .as_ref()
70            .map(|e| format!(" — [{}] {}", e.code, e.message))
71            .unwrap_or_default();
72        lines.push(format!("- {marker} `{}` ({}){}", entry.id, action, detail));
73    }
74    if result.errors_suppressed > 0 {
75        lines.push(String::new());
76        lines.push(format!(
77            "{} further failing entr(y/ies) suppressed beyond the detailed-report cap — \
78             every failing entry is still marked `error` above.",
79            result.errors_suppressed
80        ));
81    }
82    if result.applied && !result.commit_sha.is_empty() {
83        lines.push(String::new());
84        lines.push(format!("Commit: `{}`", result.commit_sha));
85    }
86    lines.join("\n")
87}
88
89/// Build the error envelope for a refused (atomic) batch. The top-level
90/// `code` is the stable `BATCH_REFUSED` token; the `ExitKind` mirrors the
91/// dominant (first-reported) entry's failure so `$?` matches the
92/// equivalent single command and the documented table (hash mismatch → 4,
93/// missing entity / mem → 3, schema/policy refusal → 5). The full
94/// [`BatchResult`](memstead_base::ops::BatchResult) rides on `details` —
95/// per-entry codes stay available without re-running.
96pub(crate) fn batch_refused_error(
97    command: &str,
98    result: &memstead_base::ops::BatchResult,
99) -> CliError {
100    let dominant = result.results.iter().find(|e| e.error.is_some());
101    let (code, failing_id, message) = match dominant {
102        Some(entry) => {
103            let err = entry.error.as_ref().expect("dominant entry has an error");
104            (err.code.as_str(), entry.id.to_string(), err.message.clone())
105        }
106        None => (
107            "",
108            String::new(),
109            format!("batch-{command} refused; nothing committed"),
110        ),
111    };
112    let kind = batch_refused_exit_kind(code);
113    let summary = format!(
114        "batch-{command} refused — {} item(s) failed, nothing committed; first failure [{}] on `{}`: {}",
115        result.failed, code, failing_id, message,
116    );
117    CliError::new(kind, "BATCH_REFUSED", summary)
118        .with_details(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
119}
120
121/// Map the dominant per-entry failure code to the process exit code,
122/// reusing the documented `0/1/3/4/5` taxonomy so a refused batch exits
123/// the same way the equivalent single command would. Unrecognised codes
124/// fall to `Validation` (5) — the bucket for schema/policy refusals,
125/// which is what most batch-entry failures are.
126pub(crate) fn batch_refused_exit_kind(code: &str) -> ExitKind {
127    match code {
128        "HASH_MISMATCH" => ExitKind::HashMismatch,
129        "ENTITY_NOT_FOUND" | "UNKNOWN_MEM" => ExitKind::NotFound,
130        _ => ExitKind::Validation,
131    }
132}
133
134/// Parse a batch `--from` file's envelope: exactly one top-level key
135/// (`array_key`, e.g. `updates` / `creates` / `relates`) holding a
136/// non-empty JSON array. Unknown top-level keys refuse with a
137/// `suggested` hint; a missing or non-array value refuses with the
138/// expected shape named.
139pub(crate) fn parse_batch_envelope(
140    path: &std::path::Path,
141    array_key: &'static str,
142) -> anyhow::Result<Vec<serde_json::Value>> {
143    let bytes = std::fs::read(path).map_err(|e| {
144        CliError::new(
145            ExitKind::Generic,
146            "INVALID_INPUT",
147            format!("failed to read {}: {e}", path.display()),
148        )
149    })?;
150    let envelope: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
151        CliError::new(
152            ExitKind::Validation,
153            "INVALID_INPUT",
154            format!("invalid JSON in {}: {e}", path.display()),
155        )
156        .with_details(serde_json::json!({
157            "path": path.display().to_string(),
158            "parser_error": e.to_string(),
159        }))
160    })?;
161    let entries_value = envelope
162        .get(array_key)
163        .cloned()
164        .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
165    let entries = match entries_value {
166        serde_json::Value::Array(a) => a,
167        _ => {
168            return Err(CliError::new(
169                ExitKind::Validation,
170                "INVALID_INPUT",
171                format!("`{array_key}` must be a JSON array"),
172            )
173            .into());
174        }
175    };
176    // Surface top-level unknown keys too (e.g. a singular typo for the
177    // expected plural key).
178    if let serde_json::Value::Object(map) = &envelope {
179        let unknown: Vec<String> = map
180            .keys()
181            .filter(|k| k.as_str() != array_key)
182            .cloned()
183            .collect();
184        if !unknown.is_empty() {
185            return Err(CliError::new(
186                ExitKind::Validation,
187                "INVALID_INPUT",
188                format!(
189                    "unknown top-level key(s) {unknown:?} — only `{array_key}: [...]` is recognised"
190                ),
191            )
192            .with_details(serde_json::json!({
193                "unknown_keys": unknown,
194                "suggested": array_key,
195            }))
196            .into());
197        }
198    }
199    if entries.is_empty() {
200        return Err(CliError::new(
201            ExitKind::Validation,
202            "INVALID_INPUT",
203            format!("{array_key}[] is empty"),
204        )
205        .into());
206    }
207    Ok(entries)
208}