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.
13///
14/// `pub(crate)` so cli.rs's dispatch_module dry-run path (D9-004) can share
15/// the same exit-code mapping the standalone `validate` subcommand uses.
16pub(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
40/// Format and print a preflight result (from executor.validate).
41pub 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        // TTY table format
97        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            // Spec symbols: v=passed, !=warning, x=failed, o=skipped
105            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
162// ---------------------------------------------------------------------------
163// Command builder
164// ---------------------------------------------------------------------------
165
166/// Build the `validate` clap subcommand.
167pub 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
191/// Register the validate subcommand on the root command.
192pub fn register_validate_command(cli: Command) -> Command {
193    cli.subcommand(validate_command())
194}
195
196// ---------------------------------------------------------------------------
197// Dispatch
198// ---------------------------------------------------------------------------
199
200/// Build a preflight result for `module_def` against `input`.
201///
202/// Calls `system.validate` via the apcore executor when available; on failure
203/// (module not registered, internal error) constructs a synthetic preflight
204/// JSON shape with the same `{ checks: [...], valid, requires_approval }`
205/// schema so callers can format and exit uniformly.
206///
207/// Used by both [`dispatch_validate`] (the standalone `validate` subcommand)
208/// and the `--dry-run` branch of `dispatch_module` in cli.rs (D9-004 — was
209/// previously two parallel implementations). Caller is responsible for
210/// running `validate_module_id` and `get_module_descriptor` lookups before
211/// calling this — the returned preflight assumes the module exists and the
212/// id has the right shape.
213pub 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            // Synthetic preflight with the same shape system.validate emits.
233            // module_id and module_lookup are passed because the caller has
234            // already validated those.
235            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
270/// Dispatch the `validate` subcommand.
271///
272/// Calls `executor.validate()` (preflight) and prints the result.
273pub 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    // Validate module ID (exits with code 2 on failure — D10-004).
284    crate::cli::validate_module_id_or_exit(module_id);
285
286    // Check module exists.
287    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    // Collect input if provided.
293    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            // Already exited above; defensive guard against caller skipping
309            // the existence check.
310            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// ---------------------------------------------------------------------------
335// Unit tests
336// ---------------------------------------------------------------------------
337
338#[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        // All passed, falls through to default.
378        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}