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.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
97pub(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
129pub(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
142pub(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 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}