use crate::CliError;
use crate::output::ExitKind;
pub(crate) fn render_batch_markdown(
command: &str,
result: &memstead_base::ops::BatchResult,
) -> String {
let header = if result.applied {
format!(
"# Batch {command} applied — {} item(s) in one commit",
result.succeeded
)
} else {
format!(
"# Batch {command} REFUSED — {} item(s) failed, nothing committed",
result.failed
)
};
let mut lines = vec![header, String::new()];
for entry in &result.results {
let marker = if entry.action == "error" {
"✗"
} else if entry.action == "not_applied" {
"·"
} else {
"✓"
};
let detail = entry
.error
.as_ref()
.map(|e| format!(" — [{}] {}", e.code, e.message))
.unwrap_or_default();
lines.push(format!(
"- {marker} `{}` ({}){}",
entry.id, entry.action, detail
));
}
if result.errors_suppressed > 0 {
lines.push(String::new());
lines.push(format!(
"{} further failing entr(y/ies) suppressed beyond the detailed-report cap — \
every failing entry is still marked `error` above.",
result.errors_suppressed
));
}
if result.applied && !result.commit_sha.is_empty() {
lines.push(String::new());
lines.push(format!("Commit: `{}`", result.commit_sha));
}
lines.join("\n")
}
pub(crate) fn batch_refused_error(
command: &str,
result: &memstead_base::ops::BatchResult,
) -> CliError {
let dominant = result.results.iter().find(|e| e.error.is_some());
let (code, failing_id, message) = match dominant {
Some(entry) => {
let err = entry.error.as_ref().expect("dominant entry has an error");
(err.code.as_str(), entry.id.to_string(), err.message.clone())
}
None => (
"",
String::new(),
format!("batch-{command} refused; nothing committed"),
),
};
let kind = batch_refused_exit_kind(code);
let summary = format!(
"batch-{command} refused — {} item(s) failed, nothing committed; first failure [{}] on `{}`: {}",
result.failed, code, failing_id, message,
);
CliError::new(kind, "BATCH_REFUSED", summary)
.with_details(serde_json::to_value(result).unwrap_or(serde_json::Value::Null))
}
pub(crate) fn batch_refused_exit_kind(code: &str) -> ExitKind {
match code {
"HASH_MISMATCH" => ExitKind::HashMismatch,
"ENTITY_NOT_FOUND" | "UNKNOWN_MEM" => ExitKind::NotFound,
_ => ExitKind::Validation,
}
}
pub(crate) fn parse_batch_envelope(
path: &std::path::Path,
array_key: &'static str,
) -> anyhow::Result<Vec<serde_json::Value>> {
let bytes = std::fs::read(path).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INVALID_INPUT",
format!("failed to read {}: {e}", path.display()),
)
})?;
let envelope: serde_json::Value = serde_json::from_slice(&bytes).map_err(|e| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("invalid JSON in {}: {e}", path.display()),
)
.with_details(serde_json::json!({
"path": path.display().to_string(),
"parser_error": e.to_string(),
}))
})?;
let entries_value = envelope
.get(array_key)
.cloned()
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()));
let entries = match entries_value {
serde_json::Value::Array(a) => a,
_ => {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("`{array_key}` must be a JSON array"),
)
.into());
}
};
if let serde_json::Value::Object(map) = &envelope {
let unknown: Vec<String> = map
.keys()
.filter(|k| k.as_str() != array_key)
.cloned()
.collect();
if !unknown.is_empty() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"unknown top-level key(s) {unknown:?} — only `{array_key}: [...]` is recognised"
),
)
.with_details(serde_json::json!({
"unknown_keys": unknown,
"suggested": array_key,
}))
.into());
}
}
if entries.is_empty() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!("{array_key}[] is empty"),
)
.into());
}
Ok(entries)
}