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) -> String {
19    let header = if result.applied {
20        format!(
21            "# Batch {command} applied — {} item(s) in one commit",
22            result.succeeded
23        )
24    } else {
25        format!(
26            "# Batch {command} REFUSED — {} item(s) failed, nothing committed",
27            result.failed
28        )
29    };
30    let mut lines = vec![header, String::new()];
31    for entry in &result.results {
32        let marker = if entry.action == "error" {
33            "✗"
34        } else if entry.action == "not_applied" {
35            "·"
36        } else {
37            "✓"
38        };
39        let detail = entry
40            .error
41            .as_ref()
42            .map(|e| format!(" — [{}] {}", e.code, e.message))
43            .unwrap_or_default();
44        lines.push(format!(
45            "- {marker} `{}` ({}){}",
46            entry.id, entry.action, detail
47        ));
48    }
49    if result.errors_suppressed > 0 {
50        lines.push(String::new());
51        lines.push(format!(
52            "{} further failing entr(y/ies) suppressed beyond the detailed-report cap — \
53             every failing entry is still marked `error` above.",
54            result.errors_suppressed
55        ));
56    }
57    if result.applied && !result.commit_sha.is_empty() {
58        lines.push(String::new());
59        lines.push(format!("Commit: `{}`", result.commit_sha));
60    }
61    lines.join("\n")
62}
63
64/// Build the error envelope for a refused (atomic) batch. The top-level
65/// `code` is the stable `BATCH_REFUSED` token; the `ExitKind` mirrors the
66/// dominant (first-reported) entry's failure so `$?` matches the
67/// equivalent single command and the documented table (hash mismatch → 4,
68/// missing entity / mem → 3, schema/policy refusal → 5). The full
69/// [`BatchResult`](memstead_base::ops::BatchResult) rides on `details` —
70/// per-entry codes stay available without re-running.
71pub(crate) fn batch_refused_error(
72    command: &str,
73    result: &memstead_base::ops::BatchResult,
74) -> CliError {
75    let dominant = result.results.iter().find(|e| e.error.is_some());
76    let (code, failing_id, message) = match dominant {
77        Some(entry) => {
78            let err = entry.error.as_ref().expect("dominant entry has an error");
79            (err.code.as_str(), entry.id.to_string(), err.message.clone())
80        }
81        None => (
82            "",
83            String::new(),
84            format!("batch-{command} refused; nothing committed"),
85        ),
86    };
87    let kind = batch_refused_exit_kind(code);
88    let summary = format!(
89        "batch-{command} refused — {} item(s) failed, nothing committed; first failure [{}] on `{}`: {}",
90        result.failed, code, failing_id, message,
91    );
92    CliError::new(kind, "BATCH_REFUSED", summary)
93        .with_details(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
94}
95
96/// Map the dominant per-entry failure code to the process exit code,
97/// reusing the documented `0/1/3/4/5` taxonomy so a refused batch exits
98/// the same way the equivalent single command would. Unrecognised codes
99/// fall to `Validation` (5) — the bucket for schema/policy refusals,
100/// which is what most batch-entry failures are.
101pub(crate) fn batch_refused_exit_kind(code: &str) -> ExitKind {
102    match code {
103        "HASH_MISMATCH" => ExitKind::HashMismatch,
104        "ENTITY_NOT_FOUND" | "UNKNOWN_MEM" => ExitKind::NotFound,
105        _ => ExitKind::Validation,
106    }
107}
108
109/// Parse a batch `--from` file's envelope: exactly one top-level key
110/// (`array_key`, e.g. `updates` / `creates` / `relates`) holding a
111/// non-empty JSON array. Unknown top-level keys refuse with a
112/// `suggested` hint; a missing or non-array value refuses with the
113/// expected shape named.
114pub(crate) fn parse_batch_envelope(
115    path: &std::path::Path,
116    array_key: &'static str,
117) -> anyhow::Result<Vec<serde_json::Value>> {
118    let bytes = std::fs::read(path).map_err(|e| {
119        CliError::new(
120            ExitKind::Generic,
121            "INVALID_INPUT",
122            format!("failed to read {}: {e}", path.display()),
123        )
124    })?;
125    let envelope: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
126        CliError::new(
127            ExitKind::Validation,
128            "INVALID_INPUT",
129            format!("invalid JSON in {}: {e}", path.display()),
130        )
131        .with_details(serde_json::json!({
132            "path": path.display().to_string(),
133            "parser_error": e.to_string(),
134        }))
135    })?;
136    let entries_value = envelope
137        .get(array_key)
138        .cloned()
139        .unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
140    let entries = match entries_value {
141        serde_json::Value::Array(a) => a,
142        _ => {
143            return Err(CliError::new(
144                ExitKind::Validation,
145                "INVALID_INPUT",
146                format!("`{array_key}` must be a JSON array"),
147            )
148            .into());
149        }
150    };
151    // Surface top-level unknown keys too (e.g. a singular typo for the
152    // expected plural key).
153    if let serde_json::Value::Object(map) = &envelope {
154        let unknown: Vec<String> = map
155            .keys()
156            .filter(|k| k.as_str() != array_key)
157            .cloned()
158            .collect();
159        if !unknown.is_empty() {
160            return Err(CliError::new(
161                ExitKind::Validation,
162                "INVALID_INPUT",
163                format!(
164                    "unknown top-level key(s) {unknown:?} — only `{array_key}: [...]` is recognised"
165                ),
166            )
167            .with_details(serde_json::json!({
168                "unknown_keys": unknown,
169                "suggested": array_key,
170            }))
171            .into());
172        }
173    }
174    if entries.is_empty() {
175        return Err(CliError::new(
176            ExitKind::Validation,
177            "INVALID_INPUT",
178            format!("{array_key}[] is empty"),
179        )
180        .into());
181    }
182    Ok(entries)
183}