memstead_cli/commands/
batch.rs1use crate::CliError;
8use crate::output::ExitKind;
9
10pub(crate) fn render_batch_markdown(
16 command: &str,
17 result: &memstead_base::ops::BatchResult,
18 dry_run: bool,
19) -> String {
20 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 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
89pub(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
121pub(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
134pub(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 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}