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    // `write_id` 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    // Batch-level warnings, the same line the single verbs render. A
83    // per-item `SHORT_ID_RESOLVED` announcement rides here, so leaving
84    // it out made the resolution visible only under `--json`.
85    if !result.warnings.is_empty() {
86        let parts: Vec<String> = result.warnings.iter().map(|w| w.to_string()).collect();
87        lines.push(String::new());
88        lines.push(format!("- Warnings: {}", parts.join("; ")));
89    }
90    if result.applied && !result.write_id.is_empty() {
91        lines.push(String::new());
92        lines.push(format!("Write: `{}`", result.write_id));
93    }
94    lines.join("\n")
95}
96
97/// Build the error envelope for a refused (atomic) batch. The top-level
98/// `code` is the stable `BATCH_REFUSED` token; the `ExitKind` mirrors the
99/// dominant (first-reported) entry's failure so `$?` matches the
100/// equivalent single command and the documented table (hash mismatch → 4,
101/// missing entity / mem → 3, schema/policy refusal → 5). The full
102/// [`BatchResult`](memstead_base::ops::BatchResult) rides on `details` —
103/// per-entry codes stay available without re-running.
104pub(crate) fn batch_refused_error(
105    command: &str,
106    result: &memstead_base::ops::BatchResult,
107) -> CliError {
108    let dominant = result.results.iter().find(|e| e.error.is_some());
109    let (code, failing_id, message) = match dominant {
110        Some(entry) => {
111            let err = entry.error.as_ref().expect("dominant entry has an error");
112            (err.code.as_str(), entry.id.to_string(), err.message.clone())
113        }
114        None => (
115            "",
116            String::new(),
117            format!("batch-{command} refused; nothing committed"),
118        ),
119    };
120    let kind = batch_refused_exit_kind(code);
121    let summary = format!(
122        "batch-{command} refused — {} item(s) failed, nothing committed; first failure [{}] on `{}`: {}",
123        result.failed, code, failing_id, message,
124    );
125    CliError::new(kind, "BATCH_REFUSED", summary)
126        .with_details(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
127}
128
129/// Map the dominant per-entry failure code to the process exit code,
130/// reusing the documented `0/1/3/4/5` taxonomy so a refused batch exits
131/// the same way the equivalent single command would. Unrecognised codes
132/// fall to `Validation` (5) — the bucket for schema/policy refusals,
133/// which is what most batch-entry failures are.
134pub(crate) fn batch_refused_exit_kind(code: &str) -> ExitKind {
135    match code {
136        "HASH_MISMATCH" => ExitKind::HashMismatch,
137        "ENTITY_NOT_FOUND" | "UNKNOWN_MEM" => ExitKind::NotFound,
138        _ => ExitKind::Validation,
139    }
140}
141
142/// Parse a batch `--from` file's envelope: exactly one top-level key
143/// (`array_key`, e.g. `updates` / `creates` / `relates`) holding a
144/// non-empty JSON array. Unknown top-level keys refuse with a
145/// `suggested` hint; a missing or non-array value refuses with the
146/// expected shape named.
147pub(crate) fn parse_batch_envelope(
148    path: &std::path::Path,
149    array_key: &'static str,
150) -> anyhow::Result<Vec<serde_json::Value>> {
151    let bytes = std::fs::read(path).map_err(|e| {
152        CliError::new(
153            ExitKind::Generic,
154            "INVALID_INPUT",
155            format!("failed to read {}: {e}", path.display()),
156        )
157    })?;
158    let envelope: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
159        CliError::new(
160            ExitKind::Validation,
161            "INVALID_INPUT",
162            format!("invalid JSON in {}: {e}", path.display()),
163        )
164        .with_details(serde_json::json!({
165            "path": path.display().to_string(),
166            "parser_error": e.to_string(),
167        }))
168    })?;
169    let entries_value = envelope
170        .get(array_key)
171        .cloned()
172        .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
173    let entries = match entries_value {
174        serde_json::Value::Array(a) => a,
175        _ => {
176            return Err(CliError::new(
177                ExitKind::Validation,
178                "INVALID_INPUT",
179                format!("`{array_key}` must be a JSON array"),
180            )
181            .into());
182        }
183    };
184    // Surface top-level unknown keys too (e.g. a singular typo for the
185    // expected plural key).
186    if let serde_json::Value::Object(map) = &envelope {
187        let unknown: Vec<String> = map
188            .keys()
189            .filter(|k| k.as_str() != array_key)
190            .cloned()
191            .collect();
192        if !unknown.is_empty() {
193            return Err(CliError::new(
194                ExitKind::Validation,
195                "INVALID_INPUT",
196                format!(
197                    "unknown top-level key(s) {unknown:?} — only `{array_key}: [...]` is recognised"
198                ),
199            )
200            .with_details(serde_json::json!({
201                "unknown_keys": unknown,
202                "suggested": array_key,
203            }))
204            .into());
205        }
206    }
207    if entries.is_empty() {
208        return Err(CliError::new(
209            ExitKind::Validation,
210            "INVALID_INPUT",
211            format!("{array_key}[] is empty"),
212        )
213        .into());
214    }
215    Ok(entries)
216}