1#![deny(warnings)]
19
20use std::path::PathBuf;
21
22use clap::{Parser, ValueEnum};
23
24use crate::models::report::{OutputFormat, ReportType};
25use crate::ui::theme::ThemePreference;
26
27#[derive(Debug, Parser)]
42#[command(
43 name = "vfa-tui",
44 version,
45 about = "Platform operator console for agentic asset governance",
46 long_about = concat!(
47 "Platform operator console for agentic asset governance.\n\n",
48 "EXIT CODES\n",
49 " 0 Success — no violations\n",
50 " 1 Compliance failures (violations, content drift, stale threshold, gate failures)\n",
51 " 2 Operational error (invalid config, missing registry, invalid flags)\n",
52 " 3 Partial catalog failure (catalog directory exists but files are corrupted)\n\n",
53 "Multiple failure conditions simultaneously → highest severity code wins (3 > 2 > 1 > 0)."
54 )
55)]
56pub struct Cli {
57 #[arg(long)]
62 pub workspace: Option<PathBuf>,
63
64 #[arg(long)]
66 pub log_file: Option<PathBuf>,
67
68 #[arg(long, value_enum, default_value_t = LogLevel::Info)]
70 pub log_level: LogLevel,
71
72 #[arg(long)]
74 pub no_color: bool,
75
76 #[arg(long, value_enum, default_value_t = ThemePreference::Auto)]
78 pub theme: ThemePreference,
79
80 #[arg(long, default_value = "~/.config/vfa/workspaces.toml")]
86 pub registry: String,
87
88 #[arg(long, default_value = "~/.config/vfa/policies.toml")]
91 pub policies: String,
92
93 #[arg(long, default_value = "~/.local/share/vfa/index.db")]
96 pub index_path: String,
97
98 #[arg(skip)]
100 pub _home_expanded: bool,
101
102 #[arg(long, value_delimiter = ',')]
107 pub report: Option<Vec<ReportTypeCli>>,
108
109 #[arg(long, default_value = "json")]
113 pub format: OutputFormatCli,
114
115 #[arg(long)]
117 pub workspace_filter: Option<String>,
118
119 #[arg(long)]
121 pub rebuild_index: bool,
122
123 #[arg(long)]
125 pub quiet: bool,
126
127 #[arg(long)]
129 pub validate_config: bool,
130
131 #[arg(long, num_args = 2, value_names = ["FORMAT", "PATH"])]
133 pub export_audit: Option<Vec<String>>,
134
135 #[arg(long)]
137 pub web: bool,
138
139 #[arg(long, default_value = "127.0.0.1:8080")]
141 pub web_bind: String,
142}
143
144impl Cli {
145 pub fn expand_home_paths(&mut self) {
148 self.registry = expand_tilde(&self.registry);
149 self.policies = expand_tilde(&self.policies);
150 self.index_path = expand_tilde(&self.index_path);
151 }
152
153 pub fn is_no_color(&self) -> bool {
160 self.no_color || std::env::var_os("NO_COLOR").is_some()
161 }
162
163 pub fn report_types(&self) -> Vec<ReportType> {
165 match &self.report {
166 None => vec![],
167 Some(types) => types.iter().map(|t| t.to_model()).collect(),
168 }
169 }
170
171 pub fn output_format(&self) -> OutputFormat {
173 self.format.to_model()
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
183pub enum ReportTypeCli {
184 Coverage,
185 Violations,
186 Drift,
187 Stale,
188 Gates,
189 Integrity,
190 Versions,
191 Dependencies,
192 Lifecycle,
193 Summary,
194 All,
195}
196
197impl ReportTypeCli {
198 pub fn to_model(self) -> ReportType {
200 match self {
201 Self::Coverage => ReportType::Coverage,
202 Self::Violations => ReportType::Violations,
203 Self::Drift => ReportType::Drift,
204 Self::Stale => ReportType::Stale,
205 Self::Gates => ReportType::Gates,
206 Self::Integrity => ReportType::Integrity,
207 Self::Versions => ReportType::Versions,
208 Self::Dependencies => ReportType::Dependencies,
209 Self::Lifecycle => ReportType::Lifecycle,
210 Self::Summary => ReportType::Summary,
211 Self::All => ReportType::All,
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
222pub enum OutputFormatCli {
223 #[default]
224 Json,
225 Markdown,
226 Table,
227}
228
229impl OutputFormatCli {
230 pub fn to_model(self) -> OutputFormat {
232 match self {
233 Self::Json => OutputFormat::Json,
234 Self::Markdown => OutputFormat::Markdown,
235 Self::Table => OutputFormat::Table,
236 }
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
246pub enum LogLevel {
247 Trace,
248 Debug,
249 Info,
250 Warn,
251 Error,
252}
253
254fn expand_tilde(path: &str) -> String {
262 if !path.starts_with('~') {
263 return path.to_string();
264 }
265
266 match std::env::var("HOME") {
267 Ok(home) => {
268 if path == "~" {
269 home
270 } else if path.starts_with("~/") {
271 format!("{}{}", home, &path[1..])
272 } else {
273 path.to_string()
274 }
275 }
276 Err(_) => path.to_string(),
277 }
278}
279
280#[cfg(test)]
285mod tests {
286 use super::*;
287 use clap::Parser;
288
289 fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
296 Cli::try_parse_from(args)
297 }
298
299 fn ok(args: &[&str]) -> Cli {
300 parse(args).expect("expected successful parse")
301 }
302
303 #[test]
308 fn default_parse_succeeds() {
309 let cli = ok(&["vfa-tui"]);
310 assert!(!cli.no_color);
311 assert!(!cli.quiet);
312 assert!(!cli.rebuild_index);
313 assert!(!cli.web);
314 assert!(cli.report.is_none());
315 assert_eq!(cli.format, OutputFormatCli::Json);
316 }
317
318 #[test]
319 fn no_color_flag() {
320 let cli = ok(&["vfa-tui", "--no-color"]);
321 assert!(cli.no_color);
322 }
323
324 #[test]
325 fn quiet_flag() {
326 let cli = ok(&["vfa-tui", "--quiet"]);
327 assert!(cli.quiet);
328 }
329
330 #[test]
331 fn rebuild_index_flag() {
332 let cli = ok(&["vfa-tui", "--rebuild-index"]);
333 assert!(cli.rebuild_index);
334 }
335
336 #[test]
337 fn validate_config_flag() {
338 let cli = ok(&["vfa-tui", "--validate-config"]);
339 assert!(cli.validate_config);
340 }
341
342 #[test]
343 fn web_flag() {
344 let cli = ok(&["vfa-tui", "--web"]);
345 assert!(cli.web);
346 }
347
348 #[test]
349 fn web_bind_custom() {
350 let cli = ok(&["vfa-tui", "--web", "--web-bind", "0.0.0.0:9090"]);
351 assert_eq!(cli.web_bind, "0.0.0.0:9090");
352 }
353
354 #[test]
359 fn registry_path() {
360 let cli = ok(&["vfa-tui", "--registry", "/custom/workspaces.toml"]);
361 assert_eq!(cli.registry, "/custom/workspaces.toml");
362 }
363
364 #[test]
365 fn policies_path() {
366 let cli = ok(&["vfa-tui", "--policies", "/custom/policies.toml"]);
367 assert_eq!(cli.policies, "/custom/policies.toml");
368 }
369
370 #[test]
371 fn index_path() {
372 let cli = ok(&["vfa-tui", "--index-path", "/tmp/test.db"]);
373 assert_eq!(cli.index_path, "/tmp/test.db");
374 }
375
376 #[test]
381 fn report_coverage() {
382 let cli = ok(&["vfa-tui", "--report", "coverage"]);
383 let types = cli.report_types();
384 assert_eq!(types, vec![ReportType::Coverage]);
385 }
386
387 #[test]
388 fn report_all_types_individually() {
389 let type_pairs = [
390 ("coverage", ReportType::Coverage),
391 ("violations", ReportType::Violations),
392 ("drift", ReportType::Drift),
393 ("stale", ReportType::Stale),
394 ("gates", ReportType::Gates),
395 ("integrity", ReportType::Integrity),
396 ("versions", ReportType::Versions),
397 ("dependencies", ReportType::Dependencies),
398 ("lifecycle", ReportType::Lifecycle),
399 ("summary", ReportType::Summary),
400 ("all", ReportType::All),
401 ];
402 for (name, expected) in &type_pairs {
403 let cli = ok(&["vfa-tui", "--report", name]);
404 let types = cli.report_types();
405 assert_eq!(types, vec![expected.clone()], "failed for '{name}'");
406 }
407 }
408
409 #[test]
410 fn report_multiple_comma_separated() {
411 let cli = ok(&["vfa-tui", "--report", "coverage,violations"]);
412 let types = cli.report_types();
413 assert_eq!(types, vec![ReportType::Coverage, ReportType::Violations]);
414 }
415
416 #[test]
417 fn report_invalid_type_exits_2() {
418 let err = parse(&["vfa-tui", "--report", "notavalidtype"]).unwrap_err();
419 assert_eq!(
421 err.exit_code(),
422 2,
423 "invalid report type should give exit code 2"
424 );
425 }
426
427 #[test]
432 fn format_json_default() {
433 let cli = ok(&["vfa-tui"]);
434 assert_eq!(cli.output_format(), OutputFormat::Json);
435 }
436
437 #[test]
438 fn format_markdown() {
439 let cli = ok(&["vfa-tui", "--format", "markdown"]);
440 assert_eq!(cli.output_format(), OutputFormat::Markdown);
441 }
442
443 #[test]
444 fn format_table() {
445 let cli = ok(&["vfa-tui", "--format", "table"]);
446 assert_eq!(cli.output_format(), OutputFormat::Table);
447 }
448
449 #[test]
450 fn format_invalid_exits_2() {
451 let err = parse(&["vfa-tui", "--format", "xml"]).unwrap_err();
452 assert_eq!(err.exit_code(), 2, "invalid format should give exit code 2");
453 }
454
455 #[test]
460 fn workspace_filter_set() {
461 let cli = ok(&["vfa-tui", "--workspace-filter", "prod-*"]);
462 assert_eq!(cli.workspace_filter.as_deref(), Some("prod-*"));
463 }
464
465 #[test]
470 fn export_audit_json() {
471 let cli = ok(&["vfa-tui", "--export-audit", "json", "/tmp/audit.json"]);
472 let parts = cli.export_audit.as_ref().unwrap();
473 assert_eq!(parts[0], "json");
474 assert_eq!(parts[1], "/tmp/audit.json");
475 }
476
477 #[test]
482 fn no_color_env_var_detected() {
483 let cli = ok(&["vfa-tui", "--no-color"]);
487 assert!(
488 cli.is_no_color(),
489 "--no-color flag should trigger is_no_color"
490 );
491
492 let cli2 = ok(&["vfa-tui"]);
493 if std::env::var_os("NO_COLOR").is_none() {
497 assert!(
498 !cli2.is_no_color(),
499 "without flag or env var, is_no_color should be false"
500 );
501 }
502 }
503
504 #[test]
509 fn unknown_flag_exits_2() {
510 let err = parse(&["vfa-tui", "--not-a-real-flag"]).unwrap_err();
511 assert_eq!(err.exit_code(), 2, "unknown flag should give exit code 2");
512 }
513
514 #[test]
519 fn combined_headless_flags() {
520 let cli = ok(&[
521 "vfa-tui",
522 "--report",
523 "coverage",
524 "--format",
525 "markdown",
526 "--quiet",
527 "--registry",
528 "/tmp/ws.toml",
529 "--policies",
530 "/tmp/pol.toml",
531 "--workspace-filter",
532 "team-*",
533 ]);
534 assert_eq!(cli.report_types(), vec![ReportType::Coverage]);
535 assert_eq!(cli.output_format(), OutputFormat::Markdown);
536 assert!(cli.quiet);
537 assert_eq!(cli.registry, "/tmp/ws.toml");
538 assert_eq!(cli.policies, "/tmp/pol.toml");
539 assert_eq!(cli.workspace_filter.as_deref(), Some("team-*"));
540 }
541
542 #[test]
543 fn log_level_debug() {
544 let cli = ok(&["vfa-tui", "--log-level", "debug"]);
545 assert_eq!(cli.log_level, LogLevel::Debug);
546 }
547
548 #[test]
553 fn theme_flag_defaults_to_auto() {
554 assert_eq!(ok(&["vfa-tui"]).theme, ThemePreference::Auto);
555 }
556
557 #[test]
558 fn theme_flag_parses_all_variants() {
559 assert_eq!(
560 ok(&["vfa-tui", "--theme", "auto"]).theme,
561 ThemePreference::Auto
562 );
563 assert_eq!(
564 ok(&["vfa-tui", "--theme", "dark"]).theme,
565 ThemePreference::Dark
566 );
567 assert_eq!(
568 ok(&["vfa-tui", "--theme", "light"]).theme,
569 ThemePreference::Light
570 );
571 }
572
573 #[test]
574 fn theme_flag_invalid_exits_2() {
575 let err = parse(&["vfa-tui", "--theme", "sepia"]).unwrap_err();
576 assert_eq!(err.exit_code(), 2, "invalid theme should give exit code 2");
577 }
578
579 #[test]
580 fn theme_and_no_color_are_independent() {
581 let cli = ok(&["vfa-tui", "--no-color", "--theme", "light"]);
584 assert!(cli.no_color);
585 assert_eq!(cli.theme, ThemePreference::Light);
586 }
587}