1use clap::Parser;
21use memstead_base::EntityId;
22use memstead_base::check::{CheckFinding, CheckKind, RecordKind, VERDICTS, Verdict};
23use memstead_base::vcs::Actor;
24use serde::Deserialize;
25
26use crate::CliError;
27use crate::output::{ExitKind, print_json, print_markdown};
28use crate::setup::CliContext;
29
30#[derive(Parser, Debug)]
31pub struct Args {
32 #[arg(required_unless_present = "from", conflicts_with = "from")]
34 pub id: Option<String>,
35
36 #[arg(long, required_unless_present = "from", conflicts_with = "from")]
39 pub verdict: Option<String>,
40
41 #[arg(long, conflicts_with = "from")]
44 pub method: Option<String>,
45
46 #[arg(long, conflicts_with = "from")]
55 pub kind: Option<String>,
56
57 #[arg(long, value_name = "JSON", conflicts_with = "from")]
64 pub finding: Option<String>,
65
66 #[arg(long, value_name = "PATH")]
73 pub from: Option<std::path::PathBuf>,
74}
75
76#[derive(Deserialize, Debug)]
80#[serde(deny_unknown_fields)]
81struct BatchPayload {
82 checks: Vec<BatchEntry>,
83}
84
85#[derive(Deserialize, Debug)]
86#[serde(deny_unknown_fields)]
87struct BatchEntry {
88 id: String,
89 verdict: String,
90 #[serde(default)]
91 method: Option<String>,
92 #[serde(default)]
93 kind: Option<String>,
94 #[serde(default)]
97 finding: Option<serde_json::Value>,
98}
99
100fn parse_kind(kind: Option<&str>) -> Result<RecordKind, CliError> {
104 match kind {
105 None => Ok(RecordKind::Engine(CheckKind::Verification)),
106 Some(s) => RecordKind::from_wire(s).ok_or_else(|| {
107 CliError::new(
108 ExitKind::Validation,
109 "INVALID_CHECK_KIND",
110 format!(
111 "unknown check kind {s:?} — the vocabulary is: {}",
112 RecordKind::vocabulary_hint()
113 ),
114 )
115 }),
116 }
117}
118
119fn parse_finding(raw: Option<serde_json::Value>) -> Result<Option<CheckFinding>, CliError> {
122 match raw {
123 None => Ok(None),
124 Some(v) => CheckFinding::from_json(v).map(Some).map_err(|reason| {
125 CliError::new(
126 ExitKind::Validation,
127 memstead_base::check::INVALID_CHECK_FINDING_CODE,
128 reason,
129 )
130 .with_details(serde_json::json!({ "shape": CheckFinding::SHAPE }))
131 }),
132 }
133}
134
135fn state_for(
138 engine: &memstead_base::Engine,
139 id: &EntityId,
140 kind: &RecordKind,
141) -> Result<memstead_base::check::CheckState, CliError> {
142 let (state, _) = match kind.engine_kind() {
143 Some(CheckKind::Conformance) => engine.entity_conformance_state(id.mem(), id.as_ref()),
144 _ => engine.entity_check_state(id.mem(), id.as_ref()),
145 }
146 .map_err(CliError::from_engine_op)?;
147 Ok(state)
148}
149
150fn parse_verdict(verdict: &str) -> Result<Verdict, CliError> {
151 Verdict::from_wire(verdict).ok_or_else(|| {
152 CliError::new(
153 ExitKind::Validation,
154 "INVALID_VERDICT",
155 format!(
156 "unknown verdict {verdict:?} — the vocabulary is: {}",
157 VERDICTS.join(", ")
158 ),
159 )
160 })
161}
162
163pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
164 if let Some(path) = &args.from {
165 return run_batch(ctx, path);
166 }
167 let id_arg = args
170 .id
171 .as_deref()
172 .expect("clap: id required without --from");
173 let verdict_arg = args
174 .verdict
175 .as_deref()
176 .expect("clap: verdict required without --from");
177 let verdict = parse_verdict(verdict_arg)?;
178 let kind = parse_kind(args.kind.as_deref())?;
179 let finding_raw = match args.finding.as_deref() {
180 None => None,
181 Some(text) => Some(
182 serde_json::from_str::<serde_json::Value>(text).map_err(|e| {
183 CliError::new(
184 ExitKind::Validation,
185 memstead_base::check::INVALID_CHECK_FINDING_CODE,
186 format!(
187 "--finding is not valid JSON ({e}) — shape {}",
188 CheckFinding::SHAPE
189 ),
190 )
191 })?,
192 ),
193 };
194 let finding = parse_finding(finding_raw)?;
195 let id = EntityId::canonical(id_arg);
196 let mut engine = ctx.cli_engine()?.into_base();
197 let client = crate::setup::cli_client_id();
198 let record = engine
199 .record_check_with(
200 id.mem(),
201 id.as_ref(),
202 verdict,
203 &kind,
204 args.method.as_deref(),
205 finding,
206 Actor::Cli,
207 Some(&client),
208 )
209 .map_err(CliError::from_engine_op)?;
210 let state = state_for(&engine, &id, &kind)?;
211 if ctx.json {
212 print_json(&serde_json::json!({
213 "entity": record.entity,
214 "verdict": record.verdict,
215 "check_state": state.as_str(),
216 "kind": record.kind.as_deref().unwrap_or("verification"),
217 "schema_ref": record.schema_ref,
218 "role": record.role,
219 "identity": record.identity,
220 "ts": record.ts,
221 "method": record.method,
222 "finding": record.finding,
223 }))?;
224 return Ok(());
225 }
226 print_markdown(&format!(
227 "Check recorded: `{}` — kind `{}`, verdict **{}**, state `{}` (role: {})",
228 record.entity,
229 record.kind.as_deref().unwrap_or("verification"),
230 record.verdict,
231 state.as_str(),
232 record.role
233 ));
234 Ok(())
235}
236
237type ParsedEntry = (
239 EntityId,
240 Verdict,
241 RecordKind,
242 Option<String>,
243 Option<CheckFinding>,
244);
245
246fn run_batch(ctx: &CliContext, path: &std::path::Path) -> anyhow::Result<()> {
249 let raw = std::fs::read_to_string(path).map_err(|e| {
250 CliError::new(
251 ExitKind::Generic,
252 "INVALID_INPUT",
253 format!("cannot read --from file {}: {e}", path.display()),
254 )
255 })?;
256 let payload: BatchPayload = serde_json::from_str(&raw).map_err(|e| {
257 CliError::new(
258 ExitKind::Validation,
259 "INVALID_INPUT",
260 format!(
261 "--from payload is not the documented shape ({e}); expected \
262 {{\"checks\": [{{\"id\", \"verdict\", \"method\"?, \"kind\"?, \"finding\"?}}, …]}}"
263 ),
264 )
265 })?;
266 if payload.checks.is_empty() {
267 return Err(CliError::new(
268 ExitKind::Validation,
269 "INVALID_INPUT",
270 "--from payload carries no checks — an empty batch records nothing",
271 )
272 .into());
273 }
274
275 let mut engine = ctx.cli_engine()?.into_base();
276
277 let mut parsed: Vec<ParsedEntry> = Vec::new();
281 let mut failures: Vec<serde_json::Value> = Vec::new();
282 for (i, entry) in payload.checks.iter().enumerate() {
283 let id = EntityId::canonical(&entry.id);
284 let mut entry_errors: Vec<serde_json::Value> = Vec::new();
285 let verdict = match parse_verdict(&entry.verdict) {
286 Ok(v) => Some(v),
287 Err(e) => {
288 entry_errors.push(serde_json::json!({
289 "code": "INVALID_VERDICT",
290 "message": e.to_string(),
291 }));
292 None
293 }
294 };
295 let kind = match parse_kind(entry.kind.as_deref()) {
296 Ok(k) => Some(k),
297 Err(e) => {
298 entry_errors.push(serde_json::json!({
299 "code": "INVALID_CHECK_KIND",
300 "message": e.to_string(),
301 }));
302 None
303 }
304 };
305 let finding = match parse_finding(entry.finding.clone()) {
306 Ok(f) => Some(f),
307 Err(e) => {
308 entry_errors.push(serde_json::json!({
309 "code": memstead_base::check::INVALID_CHECK_FINDING_CODE,
310 "message": e.to_string(),
311 }));
312 None
313 }
314 };
315 let exists = engine
316 .store()
317 .all_entities()
318 .any(|e| !e.stub && e.mem == id.mem() && e.id.0 == *id.as_ref());
319 if !exists {
320 entry_errors.push(serde_json::json!({
321 "code": "ENTITY_NOT_FOUND",
322 "message": format!("entity not found: {}", id.as_ref()),
323 }));
324 }
325 if entry_errors.is_empty() {
326 parsed.push((
327 id,
328 verdict.unwrap(),
329 kind.unwrap(),
330 entry.method.clone(),
331 finding.unwrap(),
332 ));
333 } else {
334 failures.push(serde_json::json!({
335 "index": i,
336 "id": entry.id,
337 "errors": entry_errors,
338 }));
339 }
340 }
341 if !failures.is_empty() {
342 return Err(CliError::new(
343 ExitKind::Validation,
344 "BATCH_REFUSED",
345 format!(
346 "batch check REFUSED — {} of {} entr(ies) failed validation, nothing recorded",
347 failures.len(),
348 payload.checks.len()
349 ),
350 )
351 .with_details(serde_json::json!({ "failed_entries": failures }))
352 .into());
353 }
354
355 let client = crate::setup::cli_client_id();
356 let mut recorded: Vec<serde_json::Value> = Vec::new();
357 for (id, verdict, kind, method, finding) in &parsed {
358 let record = engine
359 .record_check_with(
360 id.mem(),
361 id.as_ref(),
362 *verdict,
363 kind,
364 method.as_deref(),
365 finding.clone(),
366 Actor::Cli,
367 Some(&client),
368 )
369 .map_err(CliError::from_engine_op)?;
370 recorded.push(serde_json::json!({
371 "entity": record.entity,
372 "verdict": record.verdict,
373 "kind": record.kind.as_deref().unwrap_or("verification"),
374 "schema_ref": record.schema_ref,
375 "ts": record.ts,
376 "finding": record.finding,
377 }));
378 }
379
380 if ctx.json {
381 print_json(&serde_json::json!({
382 "recorded": recorded.len(),
383 "checks": recorded,
384 }))?;
385 return Ok(());
386 }
387 let mut md = format!("# Batch check recorded — {} entr(ies)\n\n", recorded.len());
388 for r in &recorded {
389 md.push_str(&format!(
390 "- ✓ `{}` — {} ({})\n",
391 r["entity"].as_str().unwrap_or_default(),
392 r["verdict"].as_str().unwrap_or_default(),
393 r["kind"].as_str().unwrap_or_default(),
394 ));
395 }
396 print_markdown(&md);
397 Ok(())
398}