1use clap::{Arg, Command};
5use serde_json::Value;
6use std::io::IsTerminal;
7
8pub(crate) fn first_failed_exit_code(checks: &[Value]) -> i32 {
17 let check_to_exit = |check: &str| -> i32 {
18 match check {
19 "module_id" => crate::EXIT_INVALID_INPUT,
20 "module_lookup" => crate::EXIT_MODULE_NOT_FOUND,
21 "call_chain" => crate::EXIT_MODULE_EXECUTE_ERROR,
22 "acl" => crate::EXIT_ACL_DENIED,
23 "schema" => crate::EXIT_SCHEMA_VALIDATION_ERROR,
24 "approval" => crate::EXIT_APPROVAL_DENIED,
25 "module_preflight" => crate::EXIT_MODULE_EXECUTE_ERROR,
26 _ => crate::EXIT_MODULE_EXECUTE_ERROR,
27 }
28 };
29
30 for c in checks {
31 let passed = c.get("passed").and_then(|v| v.as_bool()).unwrap_or(true);
32 if !passed {
33 let check = c.get("check").and_then(|v| v.as_str()).unwrap_or("");
34 return check_to_exit(check);
35 }
36 }
37 crate::EXIT_MODULE_EXECUTE_ERROR
38}
39
40pub fn format_preflight_result(result: &Value, format: Option<&str>) {
42 let fmt = crate::output::resolve_format(format);
43 let valid = result
44 .get("valid")
45 .and_then(|v| v.as_bool())
46 .unwrap_or(false);
47 let requires_approval = result
48 .get("requires_approval")
49 .and_then(|v| v.as_bool())
50 .unwrap_or(false);
51 let checks = result
52 .get("checks")
53 .and_then(|v| v.as_array())
54 .cloned()
55 .unwrap_or_default();
56
57 if fmt == "json" || !std::io::stdout().is_terminal() {
58 let mut payload = serde_json::Map::new();
59 payload.insert("valid".to_string(), Value::Bool(valid));
60 payload.insert(
61 "requires_approval".to_string(),
62 Value::Bool(requires_approval),
63 );
64 let checks_json: Vec<Value> = checks
65 .iter()
66 .map(|c| {
67 let mut entry = serde_json::Map::new();
68 if let Some(check) = c.get("check") {
69 entry.insert("check".to_string(), check.clone());
70 }
71 if let Some(passed) = c.get("passed") {
72 entry.insert("passed".to_string(), passed.clone());
73 }
74 if let Some(error) = c.get("error") {
75 if !error.is_null() {
76 entry.insert("error".to_string(), error.clone());
77 }
78 }
79 if let Some(warnings) = c.get("warnings") {
80 if let Some(arr) = warnings.as_array() {
81 if !arr.is_empty() {
82 entry.insert("warnings".to_string(), warnings.clone());
83 }
84 }
85 }
86 Value::Object(entry)
87 })
88 .collect();
89 payload.insert("checks".to_string(), Value::Array(checks_json));
90 println!(
91 "{}",
92 serde_json::to_string_pretty(&Value::Object(payload))
93 .unwrap_or_else(|_| "{}".to_string())
94 );
95 } else {
96 for c in &checks {
98 let passed = c.get("passed").and_then(|v| v.as_bool()).unwrap_or(false);
99 let check = c.get("check").and_then(|v| v.as_str()).unwrap_or("?");
100 let has_warnings = c
101 .get("warnings")
102 .and_then(|v| v.as_array())
103 .is_some_and(|a| !a.is_empty());
104 let sym = if passed && has_warnings {
106 "!"
107 } else if passed {
108 "v"
109 } else {
110 "x"
111 };
112 let error = c.get("error");
113 let detail = if let Some(err) = error {
114 if err.is_null() {
115 if passed && !has_warnings {
116 " OK".to_string()
117 } else if !passed {
118 " Skipped".to_string()
119 } else {
120 String::new()
121 }
122 } else if let Some(s) = err.as_str() {
123 format!(" {s}")
124 } else {
125 format!(" {err}")
126 }
127 } else if passed && !has_warnings {
128 " OK".to_string()
129 } else if !passed {
130 " Skipped".to_string()
131 } else {
132 String::new()
133 };
134 println!(" {sym} {check:<20}{detail}");
135
136 if let Some(warnings) = c.get("warnings").and_then(|v| v.as_array()) {
137 for w in warnings {
138 let wstr = w.as_str().unwrap_or("?");
139 println!(" Warning: {wstr}");
140 }
141 }
142 }
143
144 let error_count = checks
145 .iter()
146 .filter(|c| !c.get("passed").and_then(|v| v.as_bool()).unwrap_or(true))
147 .count();
148 let warning_count: usize = checks
149 .iter()
150 .map(|c| {
151 c.get("warnings")
152 .and_then(|v| v.as_array())
153 .map(|a| a.len())
154 .unwrap_or(0)
155 })
156 .sum();
157 let tag = if valid { "PASS" } else { "FAIL" };
158 println!("\nResult: {tag} ({error_count} error(s), {warning_count} warning(s))");
159 }
160}
161
162pub fn validate_command() -> Command {
168 Command::new("validate")
169 .about("Run preflight checks without executing a module")
170 .arg(
171 Arg::new("module_id")
172 .required(true)
173 .value_name("MODULE_ID")
174 .help("Module ID to validate."),
175 )
176 .arg(
177 Arg::new("input")
178 .long("input")
179 .value_name("SOURCE")
180 .help("JSON input file or '-' for stdin."),
181 )
182 .arg(
183 Arg::new("format")
184 .long("format")
185 .value_parser(["table", "json"])
186 .value_name("FORMAT")
187 .help("Output format."),
188 )
189}
190
191pub fn register_validate_command(cli: Command) -> Command {
193 cli.subcommand(validate_command())
194}
195
196pub async fn build_preflight_result(
214 apcore_executor: &apcore::Executor,
215 module_def: &apcore::registry::registry::ModuleDescriptor,
216 input: &Value,
217) -> Value {
218 let preflight_input = serde_json::json!({
219 "module_id": module_def.module_id,
220 "input": input,
221 });
222
223 match apcore_executor
224 .call("system.validate", preflight_input, None, None)
225 .await
226 {
227 Ok(preflight) => preflight,
228 Err(e) => {
229 tracing::debug!(
230 "system.validate call failed: {e}; falling back to basic schema validation"
231 );
232 let merged: std::collections::HashMap<String, Value> = match input.as_object() {
236 Some(obj) => obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
237 None => std::collections::HashMap::new(),
238 };
239 let schema_passed = if let Some(schema_obj) = module_def.input_schema.as_object() {
240 if schema_obj.contains_key("properties") {
241 crate::cli::validate_against_schema(&merged, &module_def.input_schema).is_ok()
242 } else {
243 true
244 }
245 } else {
246 true
247 };
248
249 let checks = vec![
250 serde_json::json!({"check": "module_id", "passed": true}),
251 serde_json::json!({"check": "module_lookup", "passed": true}),
252 serde_json::json!({"check": "schema", "passed": schema_passed}),
253 ];
254
255 let requires_approval = module_def
256 .annotations
257 .as_ref()
258 .map(|a| a.requires_approval)
259 .unwrap_or(false);
260
261 serde_json::json!({
262 "valid": schema_passed,
263 "requires_approval": requires_approval,
264 "checks": checks,
265 })
266 }
267 }
268}
269
270pub async fn dispatch_validate(
274 matches: &clap::ArgMatches,
275 registry: &std::sync::Arc<dyn crate::discovery::RegistryProvider>,
276 apcore_executor: &apcore::Executor,
277) {
278 let module_id = matches
279 .get_one::<String>("module_id")
280 .expect("module_id is required");
281 let format = matches.get_one::<String>("format").map(|s| s.as_str());
282
283 crate::cli::validate_module_id_or_exit(module_id);
285
286 if registry.get_module_descriptor(module_id).is_none() {
288 eprintln!("Error: Module '{module_id}' not found.");
289 std::process::exit(crate::EXIT_MODULE_NOT_FOUND);
290 }
291
292 let stdin_flag = matches.get_one::<String>("input").map(|s| s.as_str());
294 let merged =
295 match crate::cli::collect_input(stdin_flag, std::collections::HashMap::new(), false) {
296 Ok(m) => m,
297 Err(e) => {
298 eprintln!("Error: {e}");
299 std::process::exit(crate::EXIT_INVALID_INPUT);
300 }
301 };
302
303 let input_value = serde_json::to_value(&merged).unwrap_or(Value::Object(Default::default()));
304
305 let module_def = match registry.get_module_descriptor(module_id) {
306 Some(d) => d,
307 None => {
308 eprintln!("Error: Module '{module_id}' not found.");
311 std::process::exit(crate::EXIT_MODULE_NOT_FOUND);
312 }
313 };
314
315 let preflight = build_preflight_result(apcore_executor, &module_def, &input_value).await;
316 format_preflight_result(&preflight, format);
317
318 let valid = preflight
319 .get("valid")
320 .and_then(|v| v.as_bool())
321 .unwrap_or(false);
322 if valid {
323 std::process::exit(crate::EXIT_SUCCESS);
324 } else {
325 let checks = preflight
326 .get("checks")
327 .and_then(|v| v.as_array())
328 .cloned()
329 .unwrap_or_default();
330 std::process::exit(first_failed_exit_code(&checks));
331 }
332}
333
334#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn test_validate_command_builder() {
344 let cmd = validate_command();
345 assert_eq!(cmd.get_name(), "validate");
346 let args: Vec<&str> = cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
347 assert!(args.contains(&"module_id"));
348 }
349
350 #[test]
351 fn test_register_validate_command() {
352 let root = clap::Command::new("test");
353 let root = register_validate_command(root);
354 let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
355 assert!(subs.contains(&"validate"));
356 }
357
358 #[test]
359 fn test_first_failed_exit_code_module_lookup() {
360 let checks = vec![
361 serde_json::json!({"check": "module_id", "passed": true}),
362 serde_json::json!({
363 "check": "module_lookup",
364 "passed": false,
365 "error": "not found",
366 }),
367 ];
368 assert_eq!(first_failed_exit_code(&checks), 44);
369 }
370
371 #[test]
372 fn test_first_failed_exit_code_all_pass() {
373 let checks = vec![
374 serde_json::json!({"check": "module_id", "passed": true}),
375 serde_json::json!({"check": "schema", "passed": true}),
376 ];
377 assert_eq!(first_failed_exit_code(&checks), 1);
379 }
380
381 #[test]
382 fn test_first_failed_exit_code_schema() {
383 let checks = vec![serde_json::json!({
384 "check": "schema",
385 "passed": false,
386 "error": "missing field",
387 })];
388 assert_eq!(first_failed_exit_code(&checks), 45);
389 }
390
391 #[test]
392 fn test_first_failed_exit_code_acl() {
393 let checks = vec![serde_json::json!({
394 "check": "acl",
395 "passed": false,
396 })];
397 assert_eq!(first_failed_exit_code(&checks), 77);
398 }
399}