1use clap::{Arg, ArgAction, Command};
5use serde_json::Value;
6use std::io::IsTerminal;
7
8pub(crate) fn register_health_command(cli: Command) -> Command {
11 cli.subcommand(health_command())
12}
13
14pub(crate) fn register_usage_command(cli: Command) -> Command {
17 cli.subcommand(usage_command())
18}
19
20pub(crate) fn register_enable_command(cli: Command) -> Command {
23 cli.subcommand(enable_command())
24}
25
26pub(crate) fn register_disable_command(cli: Command) -> Command {
29 cli.subcommand(disable_command())
30}
31
32pub(crate) fn register_reload_command(cli: Command) -> Command {
35 cli.subcommand(reload_command())
36}
37
38pub(crate) fn register_config_command(cli: Command) -> Command {
41 cli.subcommand(config_command())
42}
43
44pub const SYSTEM_COMMANDS: &[&str] = &["config", "disable", "enable", "health", "reload", "usage"];
46
47fn 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
260enum 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
283pub(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
318fn 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 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
340fn 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
354pub 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
404pub 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
446pub 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
486pub 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
526pub 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
576pub 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 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 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
655fn 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#[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 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 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 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 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 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 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 let result = cmd.clone().try_get_matches_from(vec!["health"]);
1086 assert!(result.is_ok(), "health must default to summary mode");
1087 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}