Skip to main content

apcore_cli/
validate.rs

1// apcore-cli -- Standalone validate command (FE-11 / F1: Dry-Run).
2// Runs preflight checks without executing the module.
3
4use clap::{Arg, Command};
5use serde_json::Value;
6use std::io::IsTerminal;
7
8// ---------------------------------------------------------------------------
9// Preflight result formatting
10// ---------------------------------------------------------------------------
11
12/// Check-name to exit code mapping for the first failed check.
13fn 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
37/// Format and print a preflight result (from executor.validate).
38pub 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        // TTY table format
94        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            // Spec symbols: v=passed, !=warning, x=failed, o=skipped
102            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
159// ---------------------------------------------------------------------------
160// Command builder
161// ---------------------------------------------------------------------------
162
163/// Build the `validate` clap subcommand.
164pub 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
188/// Register the validate subcommand on the root command.
189pub fn register_validate_command(cli: Command) -> Command {
190    cli.subcommand(validate_command())
191}
192
193// ---------------------------------------------------------------------------
194// Dispatch
195// ---------------------------------------------------------------------------
196
197/// Dispatch the `validate` subcommand.
198///
199/// Calls `executor.validate()` (preflight) and prints the result.
200pub 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    // Validate module ID.
211    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    // Check module exists.
217    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    // Collect input if provided.
223    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    // Call system.validate module via executor (preflight).
236    // The apcore executor does not expose a dedicated validate() method in
237    // Rust, so we delegate to the "system.validate" module which performs
238    // the same preflight pipeline, or fall back to a synthetic result
239    // based on schema validation.
240    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            // system.validate not available -- fall back to basic schema
269            // validation. Build a synthetic preflight result.
270            let module_def = registry.get_module_descriptor(module_id);
271            let mut checks = Vec::new();
272
273            // module_id check: always passes (validated above).
274            checks.push(serde_json::json!({
275                "check": "module_id",
276                "passed": true,
277            }));
278
279            // module_lookup check.
280            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            // schema check.
291            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// ---------------------------------------------------------------------------
334// Unit tests
335// ---------------------------------------------------------------------------
336
337#[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        // All passed, falls through to default.
377        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}