1use clap::{Arg, ArgAction, Command};
5use serde_json::Value;
6use std::io::IsTerminal;
7
8pub 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
22pub const SYSTEM_COMMANDS: &[&str] = &["config", "disable", "enable", "health", "reload", "usage"];
24
25fn 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
231fn 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 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
256pub 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
309pub 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
354pub 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 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
402pub 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
447pub 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
502pub 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 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 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
585fn 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#[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}