1use clap::Parser;
21use memstead_base::EntityId;
22use memstead_base::check::{CHECK_KINDS, CheckKind, 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")]
53 pub kind: Option<String>,
54
55 #[arg(long, value_name = "PATH")]
62 pub from: Option<std::path::PathBuf>,
63}
64
65#[derive(Deserialize, Debug)]
69#[serde(deny_unknown_fields)]
70struct BatchPayload {
71 checks: Vec<BatchEntry>,
72}
73
74#[derive(Deserialize, Debug)]
75#[serde(deny_unknown_fields)]
76struct BatchEntry {
77 id: String,
78 verdict: String,
79 #[serde(default)]
80 method: Option<String>,
81 #[serde(default)]
82 kind: Option<String>,
83}
84
85fn parse_kind(kind: Option<&str>) -> Result<CheckKind, CliError> {
89 match kind {
90 None => Ok(CheckKind::Verification),
91 Some(s) => CheckKind::from_wire(s).ok_or_else(|| {
92 CliError::new(
93 ExitKind::Validation,
94 "INVALID_CHECK_KIND",
95 format!(
96 "unknown check kind {s:?} — the vocabulary is: {}",
97 CHECK_KINDS.join(", ")
98 ),
99 )
100 }),
101 }
102}
103
104fn parse_verdict(verdict: &str) -> Result<Verdict, CliError> {
105 Verdict::from_wire(verdict).ok_or_else(|| {
106 CliError::new(
107 ExitKind::Validation,
108 "INVALID_VERDICT",
109 format!(
110 "unknown verdict {verdict:?} — the vocabulary is: {}",
111 VERDICTS.join(", ")
112 ),
113 )
114 })
115}
116
117pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
118 if let Some(path) = &args.from {
119 return run_batch(ctx, path);
120 }
121 let id_arg = args
124 .id
125 .as_deref()
126 .expect("clap: id required without --from");
127 let verdict_arg = args
128 .verdict
129 .as_deref()
130 .expect("clap: verdict required without --from");
131 let verdict = parse_verdict(verdict_arg)?;
132 let kind = parse_kind(args.kind.as_deref())?;
133 let id = EntityId::canonical(id_arg);
134 let mut engine = ctx.cli_engine()?.into_base();
135 let client = crate::setup::cli_client_id();
136 let record = engine
137 .record_check(
138 id.mem(),
139 id.as_ref(),
140 verdict,
141 kind,
142 args.method.as_deref(),
143 Actor::Cli,
144 Some(&client),
145 )
146 .map_err(CliError::from_engine_op)?;
147 let (state, _) = match kind {
148 CheckKind::Verification => engine.entity_check_state(id.mem(), id.as_ref()),
149 CheckKind::Conformance => engine.entity_conformance_state(id.mem(), id.as_ref()),
150 }
151 .map_err(CliError::from_engine_op)?;
152 if ctx.json {
153 print_json(&serde_json::json!({
154 "entity": record.entity,
155 "verdict": record.verdict,
156 "check_state": state.as_str(),
157 "kind": record.kind.as_deref().unwrap_or("verification"),
158 "schema_ref": record.schema_ref,
159 "role": record.role,
160 "identity": record.identity,
161 "ts": record.ts,
162 "method": record.method,
163 }))?;
164 return Ok(());
165 }
166 print_markdown(&format!(
167 "Check recorded: `{}` — kind `{}`, verdict **{}**, state `{}` (role: {})",
168 record.entity,
169 record.kind.as_deref().unwrap_or("verification"),
170 record.verdict,
171 state.as_str(),
172 record.role
173 ));
174 Ok(())
175}
176
177fn run_batch(ctx: &CliContext, path: &std::path::Path) -> anyhow::Result<()> {
180 let raw = std::fs::read_to_string(path).map_err(|e| {
181 CliError::new(
182 ExitKind::Generic,
183 "INVALID_INPUT",
184 format!("cannot read --from file {}: {e}", path.display()),
185 )
186 })?;
187 let payload: BatchPayload = serde_json::from_str(&raw).map_err(|e| {
188 CliError::new(
189 ExitKind::Validation,
190 "INVALID_INPUT",
191 format!(
192 "--from payload is not the documented shape ({e}); expected \
193 {{\"checks\": [{{\"id\", \"verdict\", \"method\"?, \"kind\"?}}, …]}}"
194 ),
195 )
196 })?;
197 if payload.checks.is_empty() {
198 return Err(CliError::new(
199 ExitKind::Validation,
200 "INVALID_INPUT",
201 "--from payload carries no checks — an empty batch records nothing",
202 )
203 .into());
204 }
205
206 let mut engine = ctx.cli_engine()?.into_base();
207
208 let mut parsed: Vec<(EntityId, Verdict, CheckKind, Option<String>)> = Vec::new();
212 let mut failures: Vec<serde_json::Value> = Vec::new();
213 for (i, entry) in payload.checks.iter().enumerate() {
214 let id = EntityId::canonical(&entry.id);
215 let mut entry_errors: Vec<serde_json::Value> = Vec::new();
216 let verdict = match parse_verdict(&entry.verdict) {
217 Ok(v) => Some(v),
218 Err(e) => {
219 entry_errors.push(serde_json::json!({
220 "code": "INVALID_VERDICT",
221 "message": e.to_string(),
222 }));
223 None
224 }
225 };
226 let kind = match parse_kind(entry.kind.as_deref()) {
227 Ok(k) => Some(k),
228 Err(e) => {
229 entry_errors.push(serde_json::json!({
230 "code": "INVALID_CHECK_KIND",
231 "message": e.to_string(),
232 }));
233 None
234 }
235 };
236 let exists = engine
237 .store()
238 .all_entities()
239 .any(|e| !e.stub && e.mem == id.mem() && e.id.0 == *id.as_ref());
240 if !exists {
241 entry_errors.push(serde_json::json!({
242 "code": "ENTITY_NOT_FOUND",
243 "message": format!("entity not found: {}", id.as_ref()),
244 }));
245 }
246 if entry_errors.is_empty() {
247 parsed.push((id, verdict.unwrap(), kind.unwrap(), entry.method.clone()));
248 } else {
249 failures.push(serde_json::json!({
250 "index": i,
251 "id": entry.id,
252 "errors": entry_errors,
253 }));
254 }
255 }
256 if !failures.is_empty() {
257 return Err(CliError::new(
258 ExitKind::Validation,
259 "BATCH_REFUSED",
260 format!(
261 "batch check REFUSED — {} of {} entr(ies) failed validation, nothing recorded",
262 failures.len(),
263 payload.checks.len()
264 ),
265 )
266 .with_details(serde_json::json!({ "failed_entries": failures }))
267 .into());
268 }
269
270 let client = crate::setup::cli_client_id();
271 let mut recorded: Vec<serde_json::Value> = Vec::new();
272 for (id, verdict, kind, method) in &parsed {
273 let record = engine
274 .record_check(
275 id.mem(),
276 id.as_ref(),
277 *verdict,
278 *kind,
279 method.as_deref(),
280 Actor::Cli,
281 Some(&client),
282 )
283 .map_err(CliError::from_engine_op)?;
284 recorded.push(serde_json::json!({
285 "entity": record.entity,
286 "verdict": record.verdict,
287 "kind": record.kind.as_deref().unwrap_or("verification"),
288 "schema_ref": record.schema_ref,
289 "ts": record.ts,
290 }));
291 }
292
293 if ctx.json {
294 print_json(&serde_json::json!({
295 "recorded": recorded.len(),
296 "checks": recorded,
297 }))?;
298 return Ok(());
299 }
300 let mut md = format!("# Batch check recorded — {} entr(ies)\n\n", recorded.len());
301 for r in &recorded {
302 md.push_str(&format!(
303 "- ✓ `{}` — {} ({})\n",
304 r["entity"].as_str().unwrap_or_default(),
305 r["verdict"].as_str().unwrap_or_default(),
306 r["kind"].as_str().unwrap_or_default(),
307 ));
308 }
309 print_markdown(&md);
310 Ok(())
311}