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