Skip to main content

apcore_cli/
system_cmd.rs

1// apcore-cli -- System management commands (FE-11).
2// Delegates to system.* modules via executor. Graceful no-op if unavailable.
3
4use clap::{Arg, ArgAction, Command};
5use serde_json::Value;
6use std::io::IsTerminal;
7
8/// Attach the `health` subcommand to the given command. Returns the command
9/// with the subcommand added.
10pub(crate) fn register_health_command(cli: Command) -> Command {
11    cli.subcommand(health_command())
12}
13
14/// Attach the `usage` subcommand to the given command. Returns the command
15/// with the subcommand added.
16pub(crate) fn register_usage_command(cli: Command) -> Command {
17    cli.subcommand(usage_command())
18}
19
20/// Attach the `enable` subcommand to the given command. Returns the command
21/// with the subcommand added.
22pub(crate) fn register_enable_command(cli: Command) -> Command {
23    cli.subcommand(enable_command())
24}
25
26/// Attach the `disable` subcommand to the given command. Returns the command
27/// with the subcommand added.
28pub(crate) fn register_disable_command(cli: Command) -> Command {
29    cli.subcommand(disable_command())
30}
31
32/// Attach the `reload` subcommand to the given command. Returns the command
33/// with the subcommand added.
34pub(crate) fn register_reload_command(cli: Command) -> Command {
35    cli.subcommand(reload_command())
36}
37
38/// Attach the `config` subcommand group to the given command. Returns the
39/// command with the subcommand added.
40pub(crate) fn register_config_command(cli: Command) -> Command {
41    cli.subcommand(config_command())
42}
43
44/// Names of all system management subcommands.
45pub const SYSTEM_COMMANDS: &[&str] = &["config", "disable", "enable", "health", "reload", "usage"];
46
47// ---------------------------------------------------------------------------
48// Command builders
49// ---------------------------------------------------------------------------
50
51fn health_command() -> Command {
52    Command::new("health")
53        .about("Show module health status")
54        .arg(
55            Arg::new("module_id")
56                .value_name("MODULE_ID")
57                .help("Module ID for per-module detail (omit for summary)."),
58        )
59        .arg(
60            Arg::new("threshold")
61                .long("threshold")
62                .value_name("RATE")
63                .default_value("0.01")
64                .help("Error rate threshold (default: 0.01)."),
65        )
66        .arg(
67            Arg::new("all")
68                .long("all")
69                .action(ArgAction::SetTrue)
70                .help("Include healthy modules."),
71        )
72        .arg(
73            Arg::new("errors")
74                .long("errors")
75                .value_name("N")
76                .default_value("10")
77                .help("Max recent errors (module detail only)."),
78        )
79        .arg(
80            Arg::new("format")
81                .long("format")
82                .value_parser(["table", "json"])
83                .value_name("FORMAT")
84                .help("Output format."),
85        )
86}
87
88fn usage_command() -> Command {
89    Command::new("usage")
90        .about("Show module usage statistics")
91        .arg(
92            Arg::new("module_id")
93                .value_name("MODULE_ID")
94                .help("Module ID for per-module detail (omit for summary)."),
95        )
96        .arg(
97            Arg::new("period")
98                .long("period")
99                .value_name("WINDOW")
100                .default_value("24h")
101                .help("Time window: 1h, 24h, 7d, 30d (default: 24h)."),
102        )
103        .arg(
104            Arg::new("format")
105                .long("format")
106                .value_parser(["table", "json"])
107                .value_name("FORMAT")
108                .help("Output format."),
109        )
110}
111
112fn enable_command() -> Command {
113    Command::new("enable")
114        .about("Enable a disabled module at runtime")
115        .arg(
116            Arg::new("module_id")
117                .required(true)
118                .value_name("MODULE_ID")
119                .help("Module to enable."),
120        )
121        .arg(
122            Arg::new("reason")
123                .long("reason")
124                .required(true)
125                .value_name("TEXT")
126                .help("Reason for enabling (required for audit)."),
127        )
128        .arg(
129            Arg::new("yes")
130                .long("yes")
131                .short('y')
132                .action(ArgAction::SetTrue)
133                .help("Skip approval prompt."),
134        )
135        .arg(
136            Arg::new("format")
137                .long("format")
138                .value_parser(["table", "json"])
139                .value_name("FORMAT")
140                .help("Output format."),
141        )
142}
143
144fn disable_command() -> Command {
145    Command::new("disable")
146        .about("Disable a module at runtime")
147        .arg(
148            Arg::new("module_id")
149                .required(true)
150                .value_name("MODULE_ID")
151                .help("Module to disable."),
152        )
153        .arg(
154            Arg::new("reason")
155                .long("reason")
156                .required(true)
157                .value_name("TEXT")
158                .help("Reason for disabling (required for audit)."),
159        )
160        .arg(
161            Arg::new("yes")
162                .long("yes")
163                .short('y')
164                .action(ArgAction::SetTrue)
165                .help("Skip approval prompt."),
166        )
167        .arg(
168            Arg::new("format")
169                .long("format")
170                .value_parser(["table", "json"])
171                .value_name("FORMAT")
172                .help("Output format."),
173        )
174}
175
176fn reload_command() -> Command {
177    Command::new("reload")
178        .about("Hot-reload a module from disk")
179        .arg(
180            Arg::new("module_id")
181                .required(true)
182                .value_name("MODULE_ID")
183                .help("Module to reload."),
184        )
185        .arg(
186            Arg::new("reason")
187                .long("reason")
188                .required(true)
189                .value_name("TEXT")
190                .help("Reason for reload (required for audit)."),
191        )
192        .arg(
193            Arg::new("yes")
194                .long("yes")
195                .short('y')
196                .action(ArgAction::SetTrue)
197                .help("Skip approval prompt."),
198        )
199        .arg(
200            Arg::new("format")
201                .long("format")
202                .value_parser(["table", "json"])
203                .value_name("FORMAT")
204                .help("Output format."),
205        )
206}
207
208fn config_command() -> Command {
209    Command::new("config")
210        .about("Read or update runtime configuration")
211        .subcommand(
212            Command::new("get")
213                .about("Read a configuration value by dot-path key")
214                .arg(
215                    Arg::new("key")
216                        .required(true)
217                        .value_name("KEY")
218                        .help("Dot-path configuration key."),
219                ),
220        )
221        .subcommand(
222            Command::new("set")
223                .about("Update a runtime configuration value")
224                .arg(
225                    Arg::new("key")
226                        .required(true)
227                        .value_name("KEY")
228                        .help("Dot-path configuration key."),
229                )
230                .arg(
231                    Arg::new("value")
232                        .required(true)
233                        .value_name("VALUE")
234                        .help("New value (JSON or plain string)."),
235                )
236                .arg(
237                    Arg::new("reason")
238                        .long("reason")
239                        .required(true)
240                        .value_name("TEXT")
241                        .help("Reason for config change (required for audit)."),
242                )
243                .arg(
244                    Arg::new("yes")
245                        .long("yes")
246                        .short('y')
247                        .help("Bypass approval prompt for this config change.")
248                        .action(ArgAction::SetTrue),
249                )
250                .arg(
251                    Arg::new("format")
252                        .long("format")
253                        .value_parser(["table", "json"])
254                        .value_name("FORMAT")
255                        .help("Output format."),
256                ),
257        )
258}
259
260// ---------------------------------------------------------------------------
261// Dispatch helpers
262// ---------------------------------------------------------------------------
263
264/// Error returned by `call_system_module`. Preserves the
265/// `apcore::errors::ModuleError` variant so callers can route it through
266/// `crate::cli::map_module_error_to_exit_code` and emit the protocol-spec
267/// exit code consistently with `cli::dispatch_module` (no more
268/// `exit(1)`-collapse divergence between the two dispatch paths).
269enum SystemDispatchError {
270    ModuleError(Box<apcore::errors::ModuleError>),
271    NoAsyncRuntime,
272}
273
274impl std::fmt::Display for SystemDispatchError {
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        match self {
277            SystemDispatchError::ModuleError(e) => write!(f, "{e}"),
278            SystemDispatchError::NoAsyncRuntime => write!(f, "no async runtime available"),
279        }
280    }
281}
282
283/// Run the approval gate for a system command. Mirrors the `cli.rs:1086`
284/// pattern used by the main `exec` dispatcher so `--yes`, the
285/// `APCORE_CLI_AUTO_APPROVE` env var, TTY interactivity, and the
286/// 60-second timed prompt all behave consistently across `apcli enable /
287/// disable / reload / config set`. All `system.control.*` and
288/// `system.config.set` calls are operator-confirmation-required by spec
289/// (FR-SYSCMD-013); we synthesize a minimal module_def so the standard
290/// approval helper can do its job.
291///
292/// Exits 46 on denial / timeout / non-interactive — matching the main
293/// exec dispatcher's exit-code contract.
294pub(crate) fn require_approval_for_system_command(module_id: &str, auto_approve: bool) {
295    let module_def = serde_json::json!({
296        "module_id": module_id,
297        "annotations": { "requires_approval": true },
298    });
299    let result = match tokio::runtime::Handle::try_current() {
300        Ok(handle) => tokio::task::block_in_place(|| {
301            handle.block_on(crate::approval::check_approval(
302                &module_def,
303                auto_approve,
304                None,
305            ))
306        }),
307        Err(_) => {
308            eprintln!("Error: no async runtime available for approval check");
309            std::process::exit(crate::EXIT_MODULE_EXECUTE_ERROR);
310        }
311    };
312    if let Err(e) = result {
313        eprintln!("Error: {e}");
314        std::process::exit(crate::EXIT_APPROVAL_DENIED);
315    }
316}
317
318/// Call a system module via the executor, returning the result or a typed
319/// error. Preserves the `apcore::errors::ModuleError` variant instead of
320/// stringifying at the boundary.
321fn call_system_module(
322    executor: &apcore::Executor,
323    module_id: &str,
324    inputs: Value,
325) -> Result<Value, SystemDispatchError> {
326    let rt = tokio::runtime::Handle::try_current();
327    match rt {
328        Ok(handle) => {
329            // We are inside a tokio runtime -- use block_in_place.
330            tokio::task::block_in_place(|| {
331                handle
332                    .block_on(executor.call(module_id, inputs, None, None))
333                    .map_err(|e| SystemDispatchError::ModuleError(Box::new(e)))
334            })
335        }
336        Err(_) => Err(SystemDispatchError::NoAsyncRuntime),
337    }
338}
339
340/// Exit the process with the protocol-spec code for a
341/// `SystemDispatchError`. Centralises the common error tail used by every
342/// system_cmd dispatcher so all seven exit sites stay consistent.
343fn exit_on_system_error(err: SystemDispatchError) -> ! {
344    eprintln!("Error: {err}");
345    let code = match err {
346        SystemDispatchError::ModuleError(e) => {
347            crate::cli::map_module_error_to_exit_code(e.as_ref())
348        }
349        SystemDispatchError::NoAsyncRuntime => crate::EXIT_MODULE_EXECUTE_ERROR,
350    };
351    std::process::exit(code);
352}
353
354/// Dispatch the `health` subcommand.
355pub fn dispatch_health(matches: &clap::ArgMatches, executor: &apcore::Executor) {
356    let module_id = matches.get_one::<String>("module_id");
357    let format = matches.get_one::<String>("format").map(|s| s.as_str());
358    let fmt = crate::output::resolve_format(format);
359
360    let result = if let Some(mid) = module_id {
361        let errors: i64 = matches
362            .get_one::<String>("errors")
363            .and_then(|s| s.parse().ok())
364            .unwrap_or(10);
365        call_system_module(
366            executor,
367            "system.health.module",
368            serde_json::json!({"module_id": mid, "error_limit": errors}),
369        )
370    } else {
371        let threshold: f64 = matches
372            .get_one::<String>("threshold")
373            .and_then(|s| s.parse().ok())
374            .unwrap_or(0.01);
375        let include_all = matches.get_flag("all");
376        call_system_module(
377            executor,
378            "system.health.summary",
379            serde_json::json!({
380                "error_rate_threshold": threshold,
381                "include_healthy": include_all,
382            }),
383        )
384    };
385
386    match result {
387        Ok(val) => {
388            if fmt == "json" || !std::io::stdout().is_terminal() {
389                println!(
390                    "{}",
391                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
392                );
393            } else if module_id.is_some() {
394                format_health_module_tty(&val);
395            } else {
396                format_health_summary_tty(&val);
397            }
398            std::process::exit(0);
399        }
400        Err(e) => exit_on_system_error(e),
401    }
402}
403
404/// Dispatch the `usage` subcommand.
405pub fn dispatch_usage(matches: &clap::ArgMatches, executor: &apcore::Executor) {
406    let module_id = matches.get_one::<String>("module_id");
407    let period = matches
408        .get_one::<String>("period")
409        .map(|s| s.as_str())
410        .unwrap_or("24h");
411    let format = matches.get_one::<String>("format").map(|s| s.as_str());
412    let fmt = crate::output::resolve_format(format);
413
414    let result = if let Some(mid) = module_id {
415        call_system_module(
416            executor,
417            "system.usage.module",
418            serde_json::json!({"module_id": mid, "period": period}),
419        )
420    } else {
421        call_system_module(
422            executor,
423            "system.usage.summary",
424            serde_json::json!({"period": period}),
425        )
426    };
427
428    match result {
429        Ok(val) => {
430            if fmt == "json" || !std::io::stdout().is_terminal() {
431                println!(
432                    "{}",
433                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
434                );
435            } else if module_id.is_some() {
436                println!("{}", crate::output::format_exec_result(&val, "table", None));
437            } else {
438                format_usage_summary_tty(&val);
439            }
440            std::process::exit(0);
441        }
442        Err(e) => exit_on_system_error(e),
443    }
444}
445
446/// Dispatch the `enable` subcommand.
447pub fn dispatch_enable(matches: &clap::ArgMatches, executor: &apcore::Executor) {
448    let module_id = matches
449        .get_one::<String>("module_id")
450        .expect("module_id is required");
451    let reason = matches
452        .get_one::<String>("reason")
453        .expect("reason is required");
454    let auto_approve = matches.get_flag("yes");
455    let format = matches.get_one::<String>("format").map(|s| s.as_str());
456    let fmt = crate::output::resolve_format(format);
457
458    require_approval_for_system_command("system.control.toggle_feature", auto_approve);
459    let result = call_system_module(
460        executor,
461        "system.control.toggle_feature",
462        serde_json::json!({
463            "module_id": module_id,
464            "enabled": true,
465            "reason": reason,
466        }),
467    );
468
469    match result {
470        Ok(val) => {
471            if fmt == "json" || !std::io::stdout().is_terminal() {
472                println!(
473                    "{}",
474                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
475                );
476            } else {
477                println!("Module '{module_id}' enabled.");
478                println!("  Reason: {reason}");
479            }
480            std::process::exit(0);
481        }
482        Err(e) => exit_on_system_error(e),
483    }
484}
485
486/// Dispatch the `disable` subcommand.
487pub fn dispatch_disable(matches: &clap::ArgMatches, executor: &apcore::Executor) {
488    let module_id = matches
489        .get_one::<String>("module_id")
490        .expect("module_id is required");
491    let auto_approve = matches.get_flag("yes");
492    let reason = matches
493        .get_one::<String>("reason")
494        .expect("reason is required");
495    let format = matches.get_one::<String>("format").map(|s| s.as_str());
496    let fmt = crate::output::resolve_format(format);
497
498    require_approval_for_system_command("system.control.toggle_feature", auto_approve);
499    let result = call_system_module(
500        executor,
501        "system.control.toggle_feature",
502        serde_json::json!({
503            "module_id": module_id,
504            "enabled": false,
505            "reason": reason,
506        }),
507    );
508
509    match result {
510        Ok(val) => {
511            if fmt == "json" || !std::io::stdout().is_terminal() {
512                println!(
513                    "{}",
514                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
515                );
516            } else {
517                println!("Module '{module_id}' disabled.");
518                println!("  Reason: {reason}");
519            }
520            std::process::exit(0);
521        }
522        Err(e) => exit_on_system_error(e),
523    }
524}
525
526/// Dispatch the `reload` subcommand.
527pub fn dispatch_reload(matches: &clap::ArgMatches, executor: &apcore::Executor) {
528    let module_id = matches
529        .get_one::<String>("module_id")
530        .expect("module_id is required");
531    let auto_approve = matches.get_flag("yes");
532    let reason = matches
533        .get_one::<String>("reason")
534        .expect("reason is required");
535    let format = matches.get_one::<String>("format").map(|s| s.as_str());
536    let fmt = crate::output::resolve_format(format);
537
538    require_approval_for_system_command("system.control.reload_module", auto_approve);
539    let result = call_system_module(
540        executor,
541        "system.control.reload_module",
542        serde_json::json!({"module_id": module_id, "reason": reason}),
543    );
544
545    match result {
546        Ok(val) => {
547            if fmt == "json" || !std::io::stdout().is_terminal() {
548                println!(
549                    "{}",
550                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
551                );
552            } else {
553                let prev = val
554                    .get("previous_version")
555                    .and_then(|v| v.as_str())
556                    .unwrap_or("?");
557                let new = val
558                    .get("new_version")
559                    .and_then(|v| v.as_str())
560                    .unwrap_or("?");
561                let dur = val
562                    .get("reload_duration_ms")
563                    .and_then(|v| v.as_u64())
564                    .map(|v| v.to_string())
565                    .unwrap_or_else(|| "?".to_string());
566                println!("Module '{module_id}' reloaded.");
567                println!("  Version: {prev} -> {new}");
568                println!("  Duration: {dur}ms");
569            }
570            std::process::exit(0);
571        }
572        Err(e) => exit_on_system_error(e),
573    }
574}
575
576/// Dispatch the `config` subcommand group.
577pub fn dispatch_config(matches: &clap::ArgMatches, executor: &apcore::Executor) {
578    match matches.subcommand() {
579        Some(("get", sub_m)) => {
580            let key = sub_m.get_one::<String>("key").expect("key is required");
581            // Try reading from apcore Config directly.
582            match call_system_module(
583                executor,
584                "system.config.get",
585                serde_json::json!({"key": key}),
586            ) {
587                Ok(val) => {
588                    let display = val
589                        .get("value")
590                        .map(|v| v.to_string())
591                        .unwrap_or_else(|| val.to_string());
592                    println!("{key} = {display}");
593                    std::process::exit(0);
594                }
595                Err(e) => exit_on_system_error(e),
596            }
597        }
598        Some(("set", sub_m)) => {
599            let key = sub_m.get_one::<String>("key").expect("key is required");
600            let raw_value = sub_m.get_one::<String>("value").expect("value is required");
601            let reason = sub_m
602                .get_one::<String>("reason")
603                .expect("reason is required");
604            let auto_approve = sub_m.get_flag("yes");
605            let format = sub_m.get_one::<String>("format").map(|s| s.as_str());
606            let fmt = crate::output::resolve_format(format);
607
608            // Parse value as JSON; fall back to plain string.
609            let parsed: Value = serde_json::from_str(raw_value)
610                .unwrap_or_else(|_| Value::String(raw_value.clone()));
611
612            require_approval_for_system_command("system.control.update_config", auto_approve);
613            let result = call_system_module(
614                executor,
615                "system.control.update_config",
616                serde_json::json!({
617                    "key": key,
618                    "value": parsed,
619                    "reason": reason,
620                }),
621            );
622
623            match result {
624                Ok(val) => {
625                    if fmt == "json" || !std::io::stdout().is_terminal() {
626                        println!(
627                            "{}",
628                            serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
629                        );
630                    } else {
631                        let old = val
632                            .get("old_value")
633                            .map(|v| v.to_string())
634                            .unwrap_or_else(|| "?".to_string());
635                        let new = val
636                            .get("new_value")
637                            .map(|v| v.to_string())
638                            .unwrap_or_else(|| "?".to_string());
639                        println!("Config updated: {key}");
640                        println!("  {old} -> {new}");
641                        println!("  Reason: {reason}");
642                    }
643                    std::process::exit(0);
644                }
645                Err(e) => exit_on_system_error(e),
646            }
647        }
648        _ => {
649            eprintln!("Error: config requires a subcommand (get or set).");
650            std::process::exit(2);
651        }
652    }
653}
654
655// ---------------------------------------------------------------------------
656// TTY formatting helpers
657// ---------------------------------------------------------------------------
658
659fn format_health_summary_tty(result: &Value) {
660    let modules = result
661        .get("modules")
662        .and_then(|v| v.as_array())
663        .cloned()
664        .unwrap_or_default();
665    let summary = result.get("summary").cloned().unwrap_or(Value::Null);
666
667    if modules.is_empty() {
668        println!("No modules found.");
669        return;
670    }
671
672    let total = summary
673        .get("total_modules")
674        .and_then(|v| v.as_u64())
675        .unwrap_or(modules.len() as u64);
676
677    println!("Health Overview ({total} modules)\n");
678    println!(
679        "  {:<28} {:<12} {:<12} Top Error",
680        "Module", "Status", "Error Rate"
681    );
682    println!("  {:-<28} {:-<12} {:-<12} {:-<20}", "", "", "", "");
683    for m in &modules {
684        let mid = m.get("module_id").and_then(|v| v.as_str()).unwrap_or("?");
685        let status = m.get("status").and_then(|v| v.as_str()).unwrap_or("?");
686        let rate = m
687            .get("error_rate")
688            .and_then(|v| v.as_f64())
689            .map(|r| format!("{:.1}%", r * 100.0))
690            .unwrap_or_else(|| "0.0%".to_string());
691        let top = m.get("top_error");
692        let top_str = match top {
693            Some(t) if !t.is_null() => {
694                let code = t.get("code").and_then(|v| v.as_str()).unwrap_or("?");
695                let count = t
696                    .get("count")
697                    .and_then(|v| v.as_u64())
698                    .map(|c| c.to_string())
699                    .unwrap_or_else(|| "?".to_string());
700                format!("{code} ({count})")
701            }
702            _ => "--".to_string(),
703        };
704        println!("  {mid:<28} {status:<12} {rate:<12} {top_str}");
705    }
706
707    let mut parts = Vec::new();
708    for key in ["healthy", "degraded", "error"] {
709        if let Some(count) = summary.get(key).and_then(|v| v.as_u64()) {
710            if count > 0 {
711                parts.push(format!("{count} {key}"));
712            }
713        }
714    }
715    let summary_str = if parts.is_empty() {
716        "no data".to_string()
717    } else {
718        parts.join(", ")
719    };
720    println!("\nSummary: {summary_str}");
721}
722
723fn format_health_module_tty(result: &Value) {
724    let mid = result
725        .get("module_id")
726        .and_then(|v| v.as_str())
727        .unwrap_or("?");
728    let status = result
729        .get("status")
730        .and_then(|v| v.as_str())
731        .unwrap_or("unknown");
732    let total = result
733        .get("total_calls")
734        .and_then(|v| v.as_u64())
735        .unwrap_or(0);
736    let errors = result
737        .get("error_count")
738        .and_then(|v| v.as_u64())
739        .unwrap_or(0);
740    let rate = result
741        .get("error_rate")
742        .and_then(|v| v.as_f64())
743        .unwrap_or(0.0);
744    let avg = result
745        .get("avg_latency_ms")
746        .and_then(|v| v.as_f64())
747        .unwrap_or(0.0);
748    let p99 = result
749        .get("p99_latency_ms")
750        .and_then(|v| v.as_f64())
751        .unwrap_or(0.0);
752
753    println!("Module: {mid}");
754    println!("Status: {status}");
755    println!(
756        "Calls: {total} total | {errors} errors | {:.1}% error rate",
757        rate * 100.0
758    );
759    println!("Latency: {avg:.0}ms avg | {p99:.0}ms p99");
760
761    if let Some(recent) = result.get("recent_errors").and_then(|v| v.as_array()) {
762        if !recent.is_empty() {
763            println!("\nRecent Errors (top {}):", recent.len());
764            for e in recent {
765                let code = e.get("code").and_then(|v| v.as_str()).unwrap_or("?");
766                let count = e
767                    .get("count")
768                    .and_then(|v| v.as_u64())
769                    .map(|c| c.to_string())
770                    .unwrap_or_else(|| "?".to_string());
771                let last = e
772                    .get("last_occurred")
773                    .and_then(|v| v.as_str())
774                    .unwrap_or("?");
775                println!("  {code:<24} x{count}  (last: {last})");
776            }
777        }
778    }
779}
780
781fn format_usage_summary_tty(result: &Value) {
782    let modules = result
783        .get("modules")
784        .and_then(|v| v.as_array())
785        .cloned()
786        .unwrap_or_default();
787    let period = result.get("period").and_then(|v| v.as_str()).unwrap_or("?");
788
789    if modules.is_empty() {
790        println!("No usage data for period {period}.");
791        return;
792    }
793
794    println!("Usage Summary (last {period})\n");
795    println!(
796        "  {:<24} {:>8} {:>8} {:>12} {:<10}",
797        "Module", "Calls", "Errors", "Avg Latency", "Trend"
798    );
799    println!(
800        "  {:-<24} {:-<8} {:-<8} {:-<12} {:-<10}",
801        "", "", "", "", ""
802    );
803    for m in &modules {
804        let mid = m.get("module_id").and_then(|v| v.as_str()).unwrap_or("?");
805        let calls = m.get("call_count").and_then(|v| v.as_u64()).unwrap_or(0);
806        let errs = m.get("error_count").and_then(|v| v.as_u64()).unwrap_or(0);
807        let avg = m
808            .get("avg_latency_ms")
809            .and_then(|v| v.as_f64())
810            .map(|v| format!("{v:.0}ms"))
811            .unwrap_or_else(|| "0ms".to_string());
812        let trend = m.get("trend").and_then(|v| v.as_str()).unwrap_or("");
813        println!("  {mid:<24} {calls:>8} {errs:>8} {avg:>12} {trend:>10}");
814    }
815
816    let total_calls: u64 = result
817        .get("total_calls")
818        .and_then(|v| v.as_u64())
819        .unwrap_or_else(|| {
820            modules
821                .iter()
822                .filter_map(|m| m.get("call_count").and_then(|v| v.as_u64()))
823                .sum()
824        });
825    let total_errors: u64 = result
826        .get("total_errors")
827        .and_then(|v| v.as_u64())
828        .unwrap_or_else(|| {
829            modules
830                .iter()
831                .filter_map(|m| m.get("error_count").and_then(|v| v.as_u64()))
832                .sum()
833        });
834    println!("\nTotal: {total_calls} calls | {total_errors} errors");
835}
836
837// ---------------------------------------------------------------------------
838// Unit tests
839// ---------------------------------------------------------------------------
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    #[test]
846    fn test_system_commands_constant() {
847        assert!(SYSTEM_COMMANDS.contains(&"health"));
848        assert!(SYSTEM_COMMANDS.contains(&"usage"));
849        assert!(SYSTEM_COMMANDS.contains(&"enable"));
850        assert!(SYSTEM_COMMANDS.contains(&"disable"));
851        assert!(SYSTEM_COMMANDS.contains(&"reload"));
852        assert!(SYSTEM_COMMANDS.contains(&"config"));
853    }
854
855    #[test]
856    fn test_health_command_builder() {
857        let cmd = health_command();
858        assert_eq!(cmd.get_name(), "health");
859        let args: Vec<&str> = cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
860        assert!(args.contains(&"module_id"));
861        assert!(args.contains(&"threshold"));
862        assert!(args.contains(&"all"));
863    }
864
865    #[test]
866    fn test_usage_command_builder() {
867        let cmd = usage_command();
868        assert_eq!(cmd.get_name(), "usage");
869        let opts: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
870        assert!(opts.contains(&"period"));
871    }
872
873    #[test]
874    fn test_enable_command_builder() {
875        let cmd = enable_command();
876        assert_eq!(cmd.get_name(), "enable");
877        let opts: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
878        assert!(opts.contains(&"reason"));
879        assert!(opts.contains(&"yes"));
880    }
881
882    #[test]
883    fn test_config_command_has_subcommands() {
884        let cmd = config_command();
885        assert_eq!(cmd.get_name(), "config");
886        let subs: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
887        assert!(subs.contains(&"get"));
888        assert!(subs.contains(&"set"));
889    }
890
891    #[test]
892    fn test_per_subcommand_registrars_cover_all_system_commands() {
893        // Replaces the old test_register_system_commands_adds_all assertion
894        // — the deprecated `register_system_commands` wrapper was removed
895        // (review #28: zero production callers, FE-13 dispatch goes through
896        // the per-subcommand registrars table in lib.rs::register_apcli_subcommands).
897        let root = Command::new("test");
898        let root = register_health_command(root);
899        let root = register_usage_command(root);
900        let root = register_enable_command(root);
901        let root = register_disable_command(root);
902        let root = register_reload_command(root);
903        let root = register_config_command(root);
904        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
905        for name in SYSTEM_COMMANDS {
906            assert!(subs.contains(name), "missing system command: {name}");
907        }
908    }
909
910    #[test]
911    fn test_register_health_command_attaches_health() {
912        let root = register_health_command(Command::new("root"));
913        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
914        assert!(subs.contains(&"health"));
915    }
916
917    #[test]
918    fn test_register_usage_command_attaches_usage() {
919        let root = register_usage_command(Command::new("root"));
920        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
921        assert!(subs.contains(&"usage"));
922    }
923
924    #[test]
925    fn test_register_enable_command_attaches_enable() {
926        let root = register_enable_command(Command::new("root"));
927        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
928        assert!(subs.contains(&"enable"));
929    }
930
931    #[test]
932    fn test_register_disable_command_attaches_disable() {
933        let root = register_disable_command(Command::new("root"));
934        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
935        assert!(subs.contains(&"disable"));
936    }
937
938    #[test]
939    fn test_register_reload_command_attaches_reload() {
940        let root = register_reload_command(Command::new("root"));
941        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
942        assert!(subs.contains(&"reload"));
943    }
944
945    #[test]
946    fn test_register_config_command_attaches_config() {
947        let root = register_config_command(Command::new("root"));
948        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
949        assert!(subs.contains(&"config"));
950    }
951
952    #[test]
953    fn test_register_health_is_isolated() {
954        let root = register_health_command(Command::new("root"));
955        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
956        assert!(subs.contains(&"health"));
957        assert!(!subs.contains(&"usage"));
958        assert!(!subs.contains(&"enable"));
959        assert!(!subs.contains(&"disable"));
960        assert!(!subs.contains(&"reload"));
961        assert!(!subs.contains(&"config"));
962    }
963
964    #[test]
965    fn test_register_usage_is_isolated() {
966        let root = register_usage_command(Command::new("root"));
967        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
968        assert!(subs.contains(&"usage"));
969        assert!(!subs.contains(&"health"));
970        assert!(!subs.contains(&"enable"));
971    }
972
973    #[test]
974    fn test_register_config_is_isolated() {
975        let root = register_config_command(Command::new("root"));
976        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
977        assert!(subs.contains(&"config"));
978        assert!(!subs.contains(&"health"));
979        assert!(!subs.contains(&"usage"));
980    }
981
982    // -- Behavioral tests for dispatch arg-parsing (review #5) ---------------
983
984    /// Build a parsed ArgMatches the way main.rs would feed dispatch_*.
985    fn parse_subcommand(args: &[&str]) -> clap::ArgMatches {
986        let cmd = Command::new("root")
987            .subcommand(health_command())
988            .subcommand(usage_command())
989            .subcommand(enable_command())
990            .subcommand(disable_command())
991            .subcommand(reload_command())
992            .subcommand(config_command());
993        cmd.try_get_matches_from(std::iter::once("root").chain(args.iter().copied()))
994            .expect("parse must succeed for valid args")
995    }
996
997    #[test]
998    fn test_enable_command_requires_module_id_and_reason() {
999        let cmd = enable_command();
1000        // Missing reason → parse error.
1001        let result = cmd
1002            .clone()
1003            .try_get_matches_from(vec!["enable", "my.module"]);
1004        assert!(
1005            result.is_err(),
1006            "enable without --reason must fail to parse"
1007        );
1008        // Both required → ok.
1009        let result = cmd.try_get_matches_from(vec!["enable", "my.module", "--reason", "ops"]);
1010        assert!(result.is_ok(), "enable with --reason must parse");
1011    }
1012
1013    #[test]
1014    fn test_disable_command_requires_module_id_and_reason() {
1015        let cmd = disable_command();
1016        let result = cmd
1017            .clone()
1018            .try_get_matches_from(vec!["disable", "my.module"]);
1019        assert!(result.is_err());
1020        let result =
1021            cmd.try_get_matches_from(vec!["disable", "my.module", "--reason", "rolling-back"]);
1022        assert!(result.is_ok());
1023    }
1024
1025    #[test]
1026    fn test_reload_command_requires_module_id_and_reason() {
1027        let cmd = reload_command();
1028        let result = cmd
1029            .clone()
1030            .try_get_matches_from(vec!["reload", "my.module"]);
1031        assert!(result.is_err());
1032        let result =
1033            cmd.try_get_matches_from(vec!["reload", "my.module", "--reason", "config-change"]);
1034        assert!(result.is_ok());
1035    }
1036
1037    #[test]
1038    fn test_yes_flag_propagation_through_parse() {
1039        // Regression for review #9: --yes must be readable from the parsed
1040        // matches that dispatch_enable/disable/reload pass to
1041        // require_approval_for_system_command. Previously --yes was captured
1042        // only to gate an eprintln "Note" that never reached the executor.
1043        let m = parse_subcommand(&["enable", "my.module", "--reason", "ops", "--yes"]);
1044        let sub = m.subcommand_matches("enable").unwrap();
1045        assert!(
1046            sub.get_flag("yes"),
1047            "--yes flag must surface as true on dispatch_enable matches"
1048        );
1049
1050        let m = parse_subcommand(&["disable", "my.module", "--reason", "rolling-back", "-y"]);
1051        let sub = m.subcommand_matches("disable").unwrap();
1052        assert!(sub.get_flag("yes"), "-y short form must work for disable");
1053
1054        let m = parse_subcommand(&["reload", "my.module", "--reason", "config-change", "--yes"]);
1055        let sub = m.subcommand_matches("reload").unwrap();
1056        assert!(sub.get_flag("yes"));
1057    }
1058
1059    #[test]
1060    fn test_config_set_exposes_yes_flag() {
1061        // Regression for review #9: config set was missing --yes entirely.
1062        let cmd = config_command();
1063        let result = cmd.try_get_matches_from(vec![
1064            "config",
1065            "set",
1066            "feature.x",
1067            "true",
1068            "--reason",
1069            "ops",
1070            "--yes",
1071        ]);
1072        assert!(
1073            result.is_ok(),
1074            "config set must accept --yes (review #9): {:?}",
1075            result.err()
1076        );
1077        let set_m = result.unwrap().subcommand_matches("set").cloned().unwrap();
1078        assert!(set_m.get_flag("yes"), "--yes must read true on config set");
1079    }
1080
1081    #[test]
1082    fn test_health_module_id_is_optional() {
1083        let cmd = health_command();
1084        // Without module_id: summary mode.
1085        let result = cmd.clone().try_get_matches_from(vec!["health"]);
1086        assert!(result.is_ok(), "health must default to summary mode");
1087        // With module_id: per-module mode.
1088        let result = cmd.try_get_matches_from(vec!["health", "my.module"]);
1089        assert!(result.is_ok());
1090    }
1091
1092    #[test]
1093    fn test_usage_period_default_is_24h() {
1094        let cmd = usage_command();
1095        let m = cmd.try_get_matches_from(vec!["usage"]).unwrap();
1096        let period = m.get_one::<String>("period").cloned().unwrap_or_default();
1097        assert_eq!(period, "24h", "default usage period must be '24h'");
1098    }
1099}