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) -> 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
64pub(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
96pub(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
109pub(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 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}