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/// Register system management subcommands on the root command.
9///
10/// Returns the command with health, usage, enable, disable, reload, and
11/// config subcommands appended. If the caller determines system modules
12/// are unavailable it may skip calling this function entirely.
13pub fn register_system_commands(cli: Command) -> Command {
14    cli.subcommand(health_command())
15        .subcommand(usage_command())
16        .subcommand(enable_command())
17        .subcommand(disable_command())
18        .subcommand(reload_command())
19        .subcommand(config_command())
20}
21
22/// Names of all system management subcommands.
23pub const SYSTEM_COMMANDS: &[&str] = &["config", "disable", "enable", "health", "reload", "usage"];
24
25// ---------------------------------------------------------------------------
26// Command builders
27// ---------------------------------------------------------------------------
28
29fn health_command() -> Command {
30    Command::new("health")
31        .about("Show module health status")
32        .arg(
33            Arg::new("module_id")
34                .value_name("MODULE_ID")
35                .help("Module ID for per-module detail (omit for summary)."),
36        )
37        .arg(
38            Arg::new("threshold")
39                .long("threshold")
40                .value_name("RATE")
41                .default_value("0.01")
42                .help("Error rate threshold (default: 0.01)."),
43        )
44        .arg(
45            Arg::new("all")
46                .long("all")
47                .action(ArgAction::SetTrue)
48                .help("Include healthy modules."),
49        )
50        .arg(
51            Arg::new("errors")
52                .long("errors")
53                .value_name("N")
54                .default_value("10")
55                .help("Max recent errors (module detail only)."),
56        )
57        .arg(
58            Arg::new("format")
59                .long("format")
60                .value_parser(["table", "json"])
61                .value_name("FORMAT")
62                .help("Output format."),
63        )
64}
65
66fn usage_command() -> Command {
67    Command::new("usage")
68        .about("Show module usage statistics")
69        .arg(
70            Arg::new("module_id")
71                .value_name("MODULE_ID")
72                .help("Module ID for per-module detail (omit for summary)."),
73        )
74        .arg(
75            Arg::new("period")
76                .long("period")
77                .value_name("WINDOW")
78                .default_value("24h")
79                .help("Time window: 1h, 24h, 7d, 30d (default: 24h)."),
80        )
81        .arg(
82            Arg::new("format")
83                .long("format")
84                .value_parser(["table", "json"])
85                .value_name("FORMAT")
86                .help("Output format."),
87        )
88}
89
90fn enable_command() -> Command {
91    Command::new("enable")
92        .about("Enable a disabled module at runtime")
93        .arg(
94            Arg::new("module_id")
95                .required(true)
96                .value_name("MODULE_ID")
97                .help("Module to enable."),
98        )
99        .arg(
100            Arg::new("reason")
101                .long("reason")
102                .required(true)
103                .value_name("TEXT")
104                .help("Reason for enabling (required for audit)."),
105        )
106        .arg(
107            Arg::new("yes")
108                .long("yes")
109                .short('y')
110                .action(ArgAction::SetTrue)
111                .help("Skip approval prompt."),
112        )
113        .arg(
114            Arg::new("format")
115                .long("format")
116                .value_parser(["table", "json"])
117                .value_name("FORMAT")
118                .help("Output format."),
119        )
120}
121
122fn disable_command() -> Command {
123    Command::new("disable")
124        .about("Disable a module at runtime")
125        .arg(
126            Arg::new("module_id")
127                .required(true)
128                .value_name("MODULE_ID")
129                .help("Module to disable."),
130        )
131        .arg(
132            Arg::new("reason")
133                .long("reason")
134                .required(true)
135                .value_name("TEXT")
136                .help("Reason for disabling (required for audit)."),
137        )
138        .arg(
139            Arg::new("yes")
140                .long("yes")
141                .short('y')
142                .action(ArgAction::SetTrue)
143                .help("Skip approval prompt."),
144        )
145        .arg(
146            Arg::new("format")
147                .long("format")
148                .value_parser(["table", "json"])
149                .value_name("FORMAT")
150                .help("Output format."),
151        )
152}
153
154fn reload_command() -> Command {
155    Command::new("reload")
156        .about("Hot-reload a module from disk")
157        .arg(
158            Arg::new("module_id")
159                .required(true)
160                .value_name("MODULE_ID")
161                .help("Module to reload."),
162        )
163        .arg(
164            Arg::new("reason")
165                .long("reason")
166                .required(true)
167                .value_name("TEXT")
168                .help("Reason for reload (required for audit)."),
169        )
170        .arg(
171            Arg::new("yes")
172                .long("yes")
173                .short('y')
174                .action(ArgAction::SetTrue)
175                .help("Skip approval prompt."),
176        )
177        .arg(
178            Arg::new("format")
179                .long("format")
180                .value_parser(["table", "json"])
181                .value_name("FORMAT")
182                .help("Output format."),
183        )
184}
185
186fn config_command() -> Command {
187    Command::new("config")
188        .about("Read or update runtime configuration")
189        .subcommand(
190            Command::new("get")
191                .about("Read a configuration value by dot-path key")
192                .arg(
193                    Arg::new("key")
194                        .required(true)
195                        .value_name("KEY")
196                        .help("Dot-path configuration key."),
197                ),
198        )
199        .subcommand(
200            Command::new("set")
201                .about("Update a runtime configuration value")
202                .arg(
203                    Arg::new("key")
204                        .required(true)
205                        .value_name("KEY")
206                        .help("Dot-path configuration key."),
207                )
208                .arg(
209                    Arg::new("value")
210                        .required(true)
211                        .value_name("VALUE")
212                        .help("New value (JSON or plain string)."),
213                )
214                .arg(
215                    Arg::new("reason")
216                        .long("reason")
217                        .required(true)
218                        .value_name("TEXT")
219                        .help("Reason for config change (required for audit)."),
220                )
221                .arg(
222                    Arg::new("format")
223                        .long("format")
224                        .value_parser(["table", "json"])
225                        .value_name("FORMAT")
226                        .help("Output format."),
227                ),
228        )
229}
230
231// ---------------------------------------------------------------------------
232// Dispatch helpers
233// ---------------------------------------------------------------------------
234
235/// Call a system module via the executor, returning the result or an error
236/// string.
237fn call_system_module(
238    executor: &apcore::Executor,
239    module_id: &str,
240    inputs: Value,
241) -> Result<Value, String> {
242    let rt = tokio::runtime::Handle::try_current();
243    match rt {
244        Ok(handle) => {
245            // We are inside a tokio runtime -- use block_in_place.
246            tokio::task::block_in_place(|| {
247                handle
248                    .block_on(executor.call(module_id, inputs, None, None))
249                    .map_err(|e| e.to_string())
250            })
251        }
252        Err(_) => Err("no async runtime available".to_string()),
253    }
254}
255
256/// Dispatch the `health` subcommand.
257pub fn dispatch_health(matches: &clap::ArgMatches, executor: &apcore::Executor) {
258    let module_id = matches.get_one::<String>("module_id");
259    let format = matches.get_one::<String>("format").map(|s| s.as_str());
260    let fmt = crate::output::resolve_format(format);
261
262    let result = if let Some(mid) = module_id {
263        let errors: i64 = matches
264            .get_one::<String>("errors")
265            .and_then(|s| s.parse().ok())
266            .unwrap_or(10);
267        call_system_module(
268            executor,
269            "system.health.module",
270            serde_json::json!({"module_id": mid, "error_limit": errors}),
271        )
272    } else {
273        let threshold: f64 = matches
274            .get_one::<String>("threshold")
275            .and_then(|s| s.parse().ok())
276            .unwrap_or(0.01);
277        let include_all = matches.get_flag("all");
278        call_system_module(
279            executor,
280            "system.health.summary",
281            serde_json::json!({
282                "error_rate_threshold": threshold,
283                "include_healthy": include_all,
284            }),
285        )
286    };
287
288    match result {
289        Ok(val) => {
290            if fmt == "json" || !std::io::stdout().is_terminal() {
291                println!(
292                    "{}",
293                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
294                );
295            } else if module_id.is_some() {
296                format_health_module_tty(&val);
297            } else {
298                format_health_summary_tty(&val);
299            }
300            std::process::exit(0);
301        }
302        Err(e) => {
303            eprintln!("Error: {e}");
304            std::process::exit(1);
305        }
306    }
307}
308
309/// Dispatch the `usage` subcommand.
310pub fn dispatch_usage(matches: &clap::ArgMatches, executor: &apcore::Executor) {
311    let module_id = matches.get_one::<String>("module_id");
312    let period = matches
313        .get_one::<String>("period")
314        .map(|s| s.as_str())
315        .unwrap_or("24h");
316    let format = matches.get_one::<String>("format").map(|s| s.as_str());
317    let fmt = crate::output::resolve_format(format);
318
319    let result = if let Some(mid) = module_id {
320        call_system_module(
321            executor,
322            "system.usage.module",
323            serde_json::json!({"module_id": mid, "period": period}),
324        )
325    } else {
326        call_system_module(
327            executor,
328            "system.usage.summary",
329            serde_json::json!({"period": period}),
330        )
331    };
332
333    match result {
334        Ok(val) => {
335            if fmt == "json" || !std::io::stdout().is_terminal() {
336                println!(
337                    "{}",
338                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
339                );
340            } else if module_id.is_some() {
341                println!("{}", crate::output::format_exec_result(&val, "table", None));
342            } else {
343                format_usage_summary_tty(&val);
344            }
345            std::process::exit(0);
346        }
347        Err(e) => {
348            eprintln!("Error: {e}");
349            std::process::exit(1);
350        }
351    }
352}
353
354/// Dispatch the `enable` subcommand.
355pub fn dispatch_enable(matches: &clap::ArgMatches, executor: &apcore::Executor) {
356    let module_id = matches
357        .get_one::<String>("module_id")
358        .expect("module_id is required");
359    let reason = matches
360        .get_one::<String>("reason")
361        .expect("reason is required");
362    let auto_approve = matches.get_flag("yes");
363    if !auto_approve {
364        // System control modules have requires_approval=true.
365        // The executor's built-in approval gate will fire during call.
366        // Pass --yes to bypass (approval handled by executor pipeline).
367        eprintln!("Note: This command requires approval. Use --yes to bypass.");
368    }
369    let format = matches.get_one::<String>("format").map(|s| s.as_str());
370    let fmt = crate::output::resolve_format(format);
371
372    let result = call_system_module(
373        executor,
374        "system.control.toggle_feature",
375        serde_json::json!({
376            "module_id": module_id,
377            "enabled": true,
378            "reason": reason,
379        }),
380    );
381
382    match result {
383        Ok(val) => {
384            if fmt == "json" || !std::io::stdout().is_terminal() {
385                println!(
386                    "{}",
387                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
388                );
389            } else {
390                println!("Module '{module_id}' enabled.");
391                println!("  Reason: {reason}");
392            }
393            std::process::exit(0);
394        }
395        Err(e) => {
396            eprintln!("Error: {e}");
397            std::process::exit(1);
398        }
399    }
400}
401
402/// Dispatch the `disable` subcommand.
403pub fn dispatch_disable(matches: &clap::ArgMatches, executor: &apcore::Executor) {
404    let module_id = matches
405        .get_one::<String>("module_id")
406        .expect("module_id is required");
407    let auto_approve = matches.get_flag("yes");
408    if !auto_approve {
409        eprintln!("Note: This command requires approval. Use --yes to bypass.");
410    }
411    let reason = matches
412        .get_one::<String>("reason")
413        .expect("reason is required");
414    let format = matches.get_one::<String>("format").map(|s| s.as_str());
415    let fmt = crate::output::resolve_format(format);
416
417    let result = call_system_module(
418        executor,
419        "system.control.toggle_feature",
420        serde_json::json!({
421            "module_id": module_id,
422            "enabled": false,
423            "reason": reason,
424        }),
425    );
426
427    match result {
428        Ok(val) => {
429            if fmt == "json" || !std::io::stdout().is_terminal() {
430                println!(
431                    "{}",
432                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
433                );
434            } else {
435                println!("Module '{module_id}' disabled.");
436                println!("  Reason: {reason}");
437            }
438            std::process::exit(0);
439        }
440        Err(e) => {
441            eprintln!("Error: {e}");
442            std::process::exit(1);
443        }
444    }
445}
446
447/// Dispatch the `reload` subcommand.
448pub fn dispatch_reload(matches: &clap::ArgMatches, executor: &apcore::Executor) {
449    let module_id = matches
450        .get_one::<String>("module_id")
451        .expect("module_id is required");
452    let auto_approve = matches.get_flag("yes");
453    if !auto_approve {
454        eprintln!("Note: This command requires approval. Use --yes to bypass.");
455    }
456    let reason = matches
457        .get_one::<String>("reason")
458        .expect("reason is required");
459    let format = matches.get_one::<String>("format").map(|s| s.as_str());
460    let fmt = crate::output::resolve_format(format);
461
462    let result = call_system_module(
463        executor,
464        "system.control.reload_module",
465        serde_json::json!({"module_id": module_id, "reason": reason}),
466    );
467
468    match result {
469        Ok(val) => {
470            if fmt == "json" || !std::io::stdout().is_terminal() {
471                println!(
472                    "{}",
473                    serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
474                );
475            } else {
476                let prev = val
477                    .get("previous_version")
478                    .and_then(|v| v.as_str())
479                    .unwrap_or("?");
480                let new = val
481                    .get("new_version")
482                    .and_then(|v| v.as_str())
483                    .unwrap_or("?");
484                let dur = val
485                    .get("reload_duration_ms")
486                    .and_then(|v| v.as_u64())
487                    .map(|v| v.to_string())
488                    .unwrap_or_else(|| "?".to_string());
489                println!("Module '{module_id}' reloaded.");
490                println!("  Version: {prev} -> {new}");
491                println!("  Duration: {dur}ms");
492            }
493            std::process::exit(0);
494        }
495        Err(e) => {
496            eprintln!("Error: {e}");
497            std::process::exit(1);
498        }
499    }
500}
501
502/// Dispatch the `config` subcommand group.
503pub fn dispatch_config(matches: &clap::ArgMatches, executor: &apcore::Executor) {
504    match matches.subcommand() {
505        Some(("get", sub_m)) => {
506            let key = sub_m.get_one::<String>("key").expect("key is required");
507            // Try reading from apcore Config directly.
508            match call_system_module(
509                executor,
510                "system.config.get",
511                serde_json::json!({"key": key}),
512            ) {
513                Ok(val) => {
514                    let display = val
515                        .get("value")
516                        .map(|v| v.to_string())
517                        .unwrap_or_else(|| val.to_string());
518                    println!("{key} = {display}");
519                    std::process::exit(0);
520                }
521                Err(e) => {
522                    eprintln!("Error: {e}");
523                    std::process::exit(1);
524                }
525            }
526        }
527        Some(("set", sub_m)) => {
528            let key = sub_m.get_one::<String>("key").expect("key is required");
529            let raw_value = sub_m.get_one::<String>("value").expect("value is required");
530            let reason = sub_m
531                .get_one::<String>("reason")
532                .expect("reason is required");
533            let format = sub_m.get_one::<String>("format").map(|s| s.as_str());
534            let fmt = crate::output::resolve_format(format);
535
536            // Parse value as JSON; fall back to plain string.
537            let parsed: Value = serde_json::from_str(raw_value)
538                .unwrap_or_else(|_| Value::String(raw_value.clone()));
539
540            let result = call_system_module(
541                executor,
542                "system.control.update_config",
543                serde_json::json!({
544                    "key": key,
545                    "value": parsed,
546                    "reason": reason,
547                }),
548            );
549
550            match result {
551                Ok(val) => {
552                    if fmt == "json" || !std::io::stdout().is_terminal() {
553                        println!(
554                            "{}",
555                            serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string())
556                        );
557                    } else {
558                        let old = val
559                            .get("old_value")
560                            .map(|v| v.to_string())
561                            .unwrap_or_else(|| "?".to_string());
562                        let new = val
563                            .get("new_value")
564                            .map(|v| v.to_string())
565                            .unwrap_or_else(|| "?".to_string());
566                        println!("Config updated: {key}");
567                        println!("  {old} -> {new}");
568                        println!("  Reason: {reason}");
569                    }
570                    std::process::exit(0);
571                }
572                Err(e) => {
573                    eprintln!("Error: {e}");
574                    std::process::exit(1);
575                }
576            }
577        }
578        _ => {
579            eprintln!("Error: config requires a subcommand (get or set).");
580            std::process::exit(2);
581        }
582    }
583}
584
585// ---------------------------------------------------------------------------
586// TTY formatting helpers
587// ---------------------------------------------------------------------------
588
589fn format_health_summary_tty(result: &Value) {
590    let modules = result
591        .get("modules")
592        .and_then(|v| v.as_array())
593        .cloned()
594        .unwrap_or_default();
595    let summary = result.get("summary").cloned().unwrap_or(Value::Null);
596
597    if modules.is_empty() {
598        println!("No modules found.");
599        return;
600    }
601
602    let total = summary
603        .get("total_modules")
604        .and_then(|v| v.as_u64())
605        .unwrap_or(modules.len() as u64);
606
607    println!("Health Overview ({total} modules)\n");
608    println!(
609        "  {:<28} {:<12} {:<12} Top Error",
610        "Module", "Status", "Error Rate"
611    );
612    println!("  {:-<28} {:-<12} {:-<12} {:-<20}", "", "", "", "");
613    for m in &modules {
614        let mid = m.get("module_id").and_then(|v| v.as_str()).unwrap_or("?");
615        let status = m.get("status").and_then(|v| v.as_str()).unwrap_or("?");
616        let rate = m
617            .get("error_rate")
618            .and_then(|v| v.as_f64())
619            .map(|r| format!("{:.1}%", r * 100.0))
620            .unwrap_or_else(|| "0.0%".to_string());
621        let top = m.get("top_error");
622        let top_str = match top {
623            Some(t) if !t.is_null() => {
624                let code = t.get("code").and_then(|v| v.as_str()).unwrap_or("?");
625                let count = t
626                    .get("count")
627                    .and_then(|v| v.as_u64())
628                    .map(|c| c.to_string())
629                    .unwrap_or_else(|| "?".to_string());
630                format!("{code} ({count})")
631            }
632            _ => "--".to_string(),
633        };
634        println!("  {mid:<28} {status:<12} {rate:<12} {top_str}");
635    }
636
637    let mut parts = Vec::new();
638    for key in ["healthy", "degraded", "error"] {
639        if let Some(count) = summary.get(key).and_then(|v| v.as_u64()) {
640            if count > 0 {
641                parts.push(format!("{count} {key}"));
642            }
643        }
644    }
645    let summary_str = if parts.is_empty() {
646        "no data".to_string()
647    } else {
648        parts.join(", ")
649    };
650    println!("\nSummary: {summary_str}");
651}
652
653fn format_health_module_tty(result: &Value) {
654    let mid = result
655        .get("module_id")
656        .and_then(|v| v.as_str())
657        .unwrap_or("?");
658    let status = result
659        .get("status")
660        .and_then(|v| v.as_str())
661        .unwrap_or("unknown");
662    let total = result
663        .get("total_calls")
664        .and_then(|v| v.as_u64())
665        .unwrap_or(0);
666    let errors = result
667        .get("error_count")
668        .and_then(|v| v.as_u64())
669        .unwrap_or(0);
670    let rate = result
671        .get("error_rate")
672        .and_then(|v| v.as_f64())
673        .unwrap_or(0.0);
674    let avg = result
675        .get("avg_latency_ms")
676        .and_then(|v| v.as_f64())
677        .unwrap_or(0.0);
678    let p99 = result
679        .get("p99_latency_ms")
680        .and_then(|v| v.as_f64())
681        .unwrap_or(0.0);
682
683    println!("Module: {mid}");
684    println!("Status: {status}");
685    println!(
686        "Calls: {total} total | {errors} errors | {:.1}% error rate",
687        rate * 100.0
688    );
689    println!("Latency: {avg:.0}ms avg | {p99:.0}ms p99");
690
691    if let Some(recent) = result.get("recent_errors").and_then(|v| v.as_array()) {
692        if !recent.is_empty() {
693            println!("\nRecent Errors (top {}):", recent.len());
694            for e in recent {
695                let code = e.get("code").and_then(|v| v.as_str()).unwrap_or("?");
696                let count = e
697                    .get("count")
698                    .and_then(|v| v.as_u64())
699                    .map(|c| c.to_string())
700                    .unwrap_or_else(|| "?".to_string());
701                let last = e
702                    .get("last_occurred")
703                    .and_then(|v| v.as_str())
704                    .unwrap_or("?");
705                println!("  {code:<24} x{count}  (last: {last})");
706            }
707        }
708    }
709}
710
711fn format_usage_summary_tty(result: &Value) {
712    let modules = result
713        .get("modules")
714        .and_then(|v| v.as_array())
715        .cloned()
716        .unwrap_or_default();
717    let period = result.get("period").and_then(|v| v.as_str()).unwrap_or("?");
718
719    if modules.is_empty() {
720        println!("No usage data for period {period}.");
721        return;
722    }
723
724    println!("Usage Summary (last {period})\n");
725    println!(
726        "  {:<24} {:>8} {:>8} {:>12} {:<10}",
727        "Module", "Calls", "Errors", "Avg Latency", "Trend"
728    );
729    println!(
730        "  {:-<24} {:-<8} {:-<8} {:-<12} {:-<10}",
731        "", "", "", "", ""
732    );
733    for m in &modules {
734        let mid = m.get("module_id").and_then(|v| v.as_str()).unwrap_or("?");
735        let calls = m.get("call_count").and_then(|v| v.as_u64()).unwrap_or(0);
736        let errs = m.get("error_count").and_then(|v| v.as_u64()).unwrap_or(0);
737        let avg = m
738            .get("avg_latency_ms")
739            .and_then(|v| v.as_f64())
740            .map(|v| format!("{v:.0}ms"))
741            .unwrap_or_else(|| "0ms".to_string());
742        let trend = m.get("trend").and_then(|v| v.as_str()).unwrap_or("");
743        println!("  {mid:<24} {calls:>8} {errs:>8} {avg:>12} {trend:>10}");
744    }
745
746    let total_calls: u64 = result
747        .get("total_calls")
748        .and_then(|v| v.as_u64())
749        .unwrap_or_else(|| {
750            modules
751                .iter()
752                .filter_map(|m| m.get("call_count").and_then(|v| v.as_u64()))
753                .sum()
754        });
755    let total_errors: u64 = result
756        .get("total_errors")
757        .and_then(|v| v.as_u64())
758        .unwrap_or_else(|| {
759            modules
760                .iter()
761                .filter_map(|m| m.get("error_count").and_then(|v| v.as_u64()))
762                .sum()
763        });
764    println!("\nTotal: {total_calls} calls | {total_errors} errors");
765}
766
767// ---------------------------------------------------------------------------
768// Unit tests
769// ---------------------------------------------------------------------------
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn test_system_commands_constant() {
777        assert!(SYSTEM_COMMANDS.contains(&"health"));
778        assert!(SYSTEM_COMMANDS.contains(&"usage"));
779        assert!(SYSTEM_COMMANDS.contains(&"enable"));
780        assert!(SYSTEM_COMMANDS.contains(&"disable"));
781        assert!(SYSTEM_COMMANDS.contains(&"reload"));
782        assert!(SYSTEM_COMMANDS.contains(&"config"));
783    }
784
785    #[test]
786    fn test_health_command_builder() {
787        let cmd = health_command();
788        assert_eq!(cmd.get_name(), "health");
789        let args: Vec<&str> = cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
790        assert!(args.contains(&"module_id"));
791        assert!(args.contains(&"threshold"));
792        assert!(args.contains(&"all"));
793    }
794
795    #[test]
796    fn test_usage_command_builder() {
797        let cmd = usage_command();
798        assert_eq!(cmd.get_name(), "usage");
799        let opts: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
800        assert!(opts.contains(&"period"));
801    }
802
803    #[test]
804    fn test_enable_command_builder() {
805        let cmd = enable_command();
806        assert_eq!(cmd.get_name(), "enable");
807        let opts: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
808        assert!(opts.contains(&"reason"));
809        assert!(opts.contains(&"yes"));
810    }
811
812    #[test]
813    fn test_config_command_has_subcommands() {
814        let cmd = config_command();
815        assert_eq!(cmd.get_name(), "config");
816        let subs: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
817        assert!(subs.contains(&"get"));
818        assert!(subs.contains(&"set"));
819    }
820
821    #[test]
822    fn test_register_system_commands_adds_all() {
823        let root = Command::new("test");
824        let root = register_system_commands(root);
825        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
826        for name in SYSTEM_COMMANDS {
827            assert!(subs.contains(name), "missing system command: {name}");
828        }
829    }
830}