use clap::Parser;
use memstead_base::EntityId;
use memstead_base::check::{CHECK_KINDS, CheckKind, VERDICTS, Verdict};
use memstead_base::vcs::Actor;
use serde::Deserialize;
use crate::CliError;
use crate::output::{ExitKind, print_json, print_markdown};
use crate::setup::CliContext;
#[derive(Parser, Debug)]
pub struct Args {
#[arg(required_unless_present = "from", conflicts_with = "from")]
pub id: Option<String>,
#[arg(long, required_unless_present = "from", conflicts_with = "from")]
pub verdict: Option<String>,
#[arg(long, conflicts_with = "from")]
pub method: Option<String>,
#[arg(long, conflicts_with = "from")]
pub kind: Option<String>,
#[arg(long, value_name = "PATH")]
pub from: Option<std::path::PathBuf>,
}
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct BatchPayload {
checks: Vec<BatchEntry>,
}
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct BatchEntry {
id: String,
verdict: String,
#[serde(default)]
method: Option<String>,
#[serde(default)]
kind: Option<String>,
}
fn parse_kind(kind: Option<&str>) -> Result<CheckKind, CliError> {
match kind {
None => Ok(CheckKind::Verification),
Some(s) => CheckKind::from_wire(s).ok_or_else(|| {
CliError::new(
ExitKind::Validation,
"INVALID_CHECK_KIND",
format!(
"unknown check kind {s:?} — the vocabulary is: {}",
CHECK_KINDS.join(", ")
),
)
}),
}
}
fn parse_verdict(verdict: &str) -> Result<Verdict, CliError> {
Verdict::from_wire(verdict).ok_or_else(|| {
CliError::new(
ExitKind::Validation,
"INVALID_VERDICT",
format!(
"unknown verdict {verdict:?} — the vocabulary is: {}",
VERDICTS.join(", ")
),
)
})
}
pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
if let Some(path) = &args.from {
return run_batch(ctx, path);
}
let id_arg = args
.id
.as_deref()
.expect("clap: id required without --from");
let verdict_arg = args
.verdict
.as_deref()
.expect("clap: verdict required without --from");
let verdict = parse_verdict(verdict_arg)?;
let kind = parse_kind(args.kind.as_deref())?;
let id = EntityId::canonical(id_arg);
let mut engine = ctx.cli_engine()?.into_base();
let client = crate::setup::cli_client_id();
let record = engine
.record_check(
id.mem(),
id.as_ref(),
verdict,
kind,
args.method.as_deref(),
Actor::Cli,
Some(&client),
)
.map_err(CliError::from_engine_op)?;
let (state, _) = match kind {
CheckKind::Verification => engine.entity_check_state(id.mem(), id.as_ref()),
CheckKind::Conformance => engine.entity_conformance_state(id.mem(), id.as_ref()),
}
.map_err(CliError::from_engine_op)?;
if ctx.json {
print_json(&serde_json::json!({
"entity": record.entity,
"verdict": record.verdict,
"check_state": state.as_str(),
"kind": record.kind.as_deref().unwrap_or("verification"),
"schema_ref": record.schema_ref,
"role": record.role,
"identity": record.identity,
"ts": record.ts,
"method": record.method,
}))?;
return Ok(());
}
print_markdown(&format!(
"Check recorded: `{}` — kind `{}`, verdict **{}**, state `{}` (role: {})",
record.entity,
record.kind.as_deref().unwrap_or("verification"),
record.verdict,
state.as_str(),
record.role
));
Ok(())
}
fn run_batch(ctx: &CliContext, path: &std::path::Path) -> anyhow::Result<()> {
let raw = std::fs::read_to_string(path).map_err(|e| {
CliError::new(
ExitKind::Generic,
"INVALID_INPUT",
format!("cannot read --from file {}: {e}", path.display()),
)
})?;
let payload: BatchPayload = serde_json::from_str(&raw).map_err(|e| {
CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
format!(
"--from payload is not the documented shape ({e}); expected \
{{\"checks\": [{{\"id\", \"verdict\", \"method\"?, \"kind\"?}}, …]}}"
),
)
})?;
if payload.checks.is_empty() {
return Err(CliError::new(
ExitKind::Validation,
"INVALID_INPUT",
"--from payload carries no checks — an empty batch records nothing",
)
.into());
}
let mut engine = ctx.cli_engine()?.into_base();
let mut parsed: Vec<(EntityId, Verdict, CheckKind, Option<String>)> = Vec::new();
let mut failures: Vec<serde_json::Value> = Vec::new();
for (i, entry) in payload.checks.iter().enumerate() {
let id = EntityId::canonical(&entry.id);
let mut entry_errors: Vec<serde_json::Value> = Vec::new();
let verdict = match parse_verdict(&entry.verdict) {
Ok(v) => Some(v),
Err(e) => {
entry_errors.push(serde_json::json!({
"code": "INVALID_VERDICT",
"message": e.to_string(),
}));
None
}
};
let kind = match parse_kind(entry.kind.as_deref()) {
Ok(k) => Some(k),
Err(e) => {
entry_errors.push(serde_json::json!({
"code": "INVALID_CHECK_KIND",
"message": e.to_string(),
}));
None
}
};
let exists = engine
.store()
.all_entities()
.any(|e| !e.stub && e.mem == id.mem() && e.id.0 == *id.as_ref());
if !exists {
entry_errors.push(serde_json::json!({
"code": "ENTITY_NOT_FOUND",
"message": format!("entity not found: {}", id.as_ref()),
}));
}
if entry_errors.is_empty() {
parsed.push((id, verdict.unwrap(), kind.unwrap(), entry.method.clone()));
} else {
failures.push(serde_json::json!({
"index": i,
"id": entry.id,
"errors": entry_errors,
}));
}
}
if !failures.is_empty() {
return Err(CliError::new(
ExitKind::Validation,
"BATCH_REFUSED",
format!(
"batch check REFUSED — {} of {} entr(ies) failed validation, nothing recorded",
failures.len(),
payload.checks.len()
),
)
.with_details(serde_json::json!({ "failed_entries": failures }))
.into());
}
let client = crate::setup::cli_client_id();
let mut recorded: Vec<serde_json::Value> = Vec::new();
for (id, verdict, kind, method) in &parsed {
let record = engine
.record_check(
id.mem(),
id.as_ref(),
*verdict,
*kind,
method.as_deref(),
Actor::Cli,
Some(&client),
)
.map_err(CliError::from_engine_op)?;
recorded.push(serde_json::json!({
"entity": record.entity,
"verdict": record.verdict,
"kind": record.kind.as_deref().unwrap_or("verification"),
"schema_ref": record.schema_ref,
"ts": record.ts,
}));
}
if ctx.json {
print_json(&serde_json::json!({
"recorded": recorded.len(),
"checks": recorded,
}))?;
return Ok(());
}
let mut md = format!("# Batch check recorded — {} entr(ies)\n\n", recorded.len());
for r in &recorded {
md.push_str(&format!(
"- ✓ `{}` — {} ({})\n",
r["entity"].as_str().unwrap_or_default(),
r["verdict"].as_str().unwrap_or_default(),
r["kind"].as_str().unwrap_or_default(),
));
}
print_markdown(&md);
Ok(())
}