Skip to main content

vfa_tui/
cli.rs

1//! CLI argument parsing — clap 4 derive interface (Tasks 9.9 / 9.10).
2//!
3//! # Exit codes (documented per Req 18.5)
4//!
5//! ```text
6//! 0 — Success, no violations
7//! 1 — Compliance failures detected (violations, content drift, stale assets
8//!     exceeding threshold, gate failures)
9//! 2 — Operational error (invalid config, missing registry, missing catalog
10//!     directory, inaccessible resources); also used for usage errors
11//!     (unrecognized flags, invalid values)
12//! 3 — Partial catalog failure (catalog directory exists but individual files
13//!     are corrupted or unreadable)
14//! ```
15//!
16//! Multiple conditions → highest severity wins (3 > 2 > 1 > 0).
17
18#![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// ---------------------------------------------------------------------------
28// Cli
29// ---------------------------------------------------------------------------
30
31/// Platform operator console for agentic asset governance.
32///
33/// # Exit codes
34///
35/// ```text
36/// 0  Success — no violations
37/// 1  Compliance failures (violations, content drift, stale threshold, gate failures)
38/// 2  Operational error (invalid config, missing registry, bad flags)
39/// 3  Partial catalog failure (catalog dir exists but files corrupted)
40/// ```
41#[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    // -----------------------------------------------------------------------
58    // Pre-existing flags (preserved)
59    // -----------------------------------------------------------------------
60    /// Path to the workspace root (auto-detected if omitted).
61    #[arg(long)]
62    pub workspace: Option<PathBuf>,
63
64    /// Path to the log file for audit output.
65    #[arg(long)]
66    pub log_file: Option<PathBuf>,
67
68    /// Logging verbosity level.
69    #[arg(long, value_enum, default_value_t = LogLevel::Info)]
70    pub log_level: LogLevel,
71
72    /// Disable colored output (also honoured via NO_COLOR env var).
73    #[arg(long)]
74    pub no_color: bool,
75
76    /// Color theme mode: `auto` (detect terminal background), `dark`, or `light`.
77    #[arg(long, value_enum, default_value_t = ThemePreference::Auto)]
78    pub theme: ThemePreference,
79
80    // -----------------------------------------------------------------------
81    // New flags (Req 26.1–26.8)
82    // -----------------------------------------------------------------------
83    /// Path to the workspace registry TOML file.
84    /// Bug #3: Tilde (~) expanded after clap processing via expand_home_paths().
85    #[arg(long, default_value = "~/.config/vfa/workspaces.toml")]
86    pub registry: String,
87
88    /// Path to the policy rules TOML file.
89    /// Bug #3: Tilde (~) expanded after clap processing via expand_home_paths().
90    #[arg(long, default_value = "~/.config/vfa/policies.toml")]
91    pub policies: String,
92
93    /// Path to the SQLite index file.
94    /// Bug #3: Tilde (~) expanded after clap processing via expand_home_paths().
95    #[arg(long, default_value = "~/.local/share/vfa/index.db")]
96    pub index_path: String,
97
98    /// Internal marker: whether home expansion has been applied to default paths.
99    #[arg(skip)]
100    pub _home_expanded: bool,
101
102    /// Produce a headless report instead of launching the TUI.
103    ///
104    /// Supported types: coverage, violations, drift, stale, gates, integrity,
105    /// versions, dependencies, lifecycle, summary, all.
106    #[arg(long, value_delimiter = ',')]
107    pub report: Option<Vec<ReportTypeCli>>,
108
109    /// Output format for headless mode.
110    ///
111    /// Supported formats: json (default), markdown, table.
112    #[arg(long, default_value = "json")]
113    pub format: OutputFormatCli,
114
115    /// Restrict headless operations to workspaces matching this glob pattern.
116    #[arg(long)]
117    pub workspace_filter: Option<String>,
118
119    /// Force a complete index rebuild from a fresh filesystem scan.
120    #[arg(long)]
121    pub rebuild_index: bool,
122
123    /// Suppress progress output; emit only the final structured report.
124    #[arg(long)]
125    pub quiet: bool,
126
127    /// Parse all configuration files, report errors, and exit without running.
128    #[arg(long)]
129    pub validate_config: bool,
130
131    /// Export the audit log.  Format: `<format> <output-path>` (e.g. `json /tmp/audit.json`).
132    #[arg(long, num_args = 2, value_names = ["FORMAT", "PATH"])]
133    pub export_audit: Option<Vec<String>>,
134
135    /// Start the embedded web server (stretch goal, Req 33).
136    #[arg(long)]
137    pub web: bool,
138
139    /// Web server bind address (only used when --web is active).
140    #[arg(long, default_value = "127.0.0.1:8080")]
141    pub web_bind: String,
142}
143
144impl Cli {
145    /// Expand home directory (~) in default paths.
146    /// This should be called after parsing to resolve ~ to the user's home.
147    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    /// Returns `true` when color output should be disabled.
154    ///
155    /// Color is disabled when either `--no-color` was passed **or** the
156    /// `NO_COLOR` environment variable is set to any value (per no-color.org,
157    /// Req 29.1 / 29.3).  This only affects formatting; it never alters report
158    /// data (Req 27.4).
159    pub fn is_no_color(&self) -> bool {
160        self.no_color || std::env::var_os("NO_COLOR").is_some()
161    }
162
163    /// Convert the CLI `--report` list into `models::ReportType` values.
164    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    /// Convert the CLI `--format` into `models::OutputFormat`.
172    pub fn output_format(&self) -> OutputFormat {
173        self.format.to_model()
174    }
175}
176
177// ---------------------------------------------------------------------------
178// ReportTypeCli — clap ValueEnum wrapper for ReportType
179// ---------------------------------------------------------------------------
180
181/// Headless report type (CLI parse target; maps to [`ReportType`]).
182#[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    /// Convert to the canonical model enum.
199    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// ---------------------------------------------------------------------------
217// OutputFormatCli — clap ValueEnum wrapper for OutputFormat
218// ---------------------------------------------------------------------------
219
220/// Output format selector (CLI parse target; maps to [`OutputFormat`]).
221#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
222pub enum OutputFormatCli {
223    #[default]
224    Json,
225    Markdown,
226    Table,
227}
228
229impl OutputFormatCli {
230    /// Convert to the canonical model enum.
231    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// ---------------------------------------------------------------------------
241// LogLevel
242// ---------------------------------------------------------------------------
243
244/// Supported log levels.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
246pub enum LogLevel {
247    Trace,
248    Debug,
249    Info,
250    Warn,
251    Error,
252}
253
254// ---------------------------------------------------------------------------
255// Home directory expansion helper
256// ---------------------------------------------------------------------------
257
258/// Expand `~` to the user's home directory.
259/// If the path starts with `~/`, replace the `~` with the home directory.
260/// Otherwise, return the path unchanged.
261fn 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// ---------------------------------------------------------------------------
281// Tests (Task 9.10)
282// ---------------------------------------------------------------------------
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use clap::Parser;
288
289    // -----------------------------------------------------------------------
290    // Helper
291    // -----------------------------------------------------------------------
292
293    /// Parse a `Cli` from a string slice, returning the parsed struct on
294    /// success or the error text on failure.
295    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    // -----------------------------------------------------------------------
304    // Basic flags
305    // -----------------------------------------------------------------------
306
307    #[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    // -----------------------------------------------------------------------
355    // --registry / --policies / --index-path
356    // -----------------------------------------------------------------------
357
358    #[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    // -----------------------------------------------------------------------
377    // --report
378    // -----------------------------------------------------------------------
379
380    #[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        // clap exits with UsageError for invalid enum values
420        assert_eq!(
421            err.exit_code(),
422            2,
423            "invalid report type should give exit code 2"
424        );
425    }
426
427    // -----------------------------------------------------------------------
428    // --format
429    // -----------------------------------------------------------------------
430
431    #[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    // -----------------------------------------------------------------------
456    // --workspace-filter
457    // -----------------------------------------------------------------------
458
459    #[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    // -----------------------------------------------------------------------
466    // --export-audit
467    // -----------------------------------------------------------------------
468
469    #[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    // -----------------------------------------------------------------------
478    // NO_COLOR env var (Req 29.3)
479    // -----------------------------------------------------------------------
480
481    #[test]
482    fn no_color_env_var_detected() {
483        // is_no_color() should return true when NO_COLOR is set.
484        // We cannot set the env var in a test safely (races), so test the
485        // flag-based path and verify the logic via a separate helper.
486        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        // Without the env var, should be false (env var may or may not be set in
494        // the test environment — we can't control that, so we only test the flag path).
495        // This assertion is only safe when NO_COLOR is absent.
496        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    // -----------------------------------------------------------------------
505    // Unknown flag → exit 2
506    // -----------------------------------------------------------------------
507
508    #[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    // -----------------------------------------------------------------------
515    // Combined valid flag combinations
516    // -----------------------------------------------------------------------
517
518    #[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    // -----------------------------------------------------------------------
549    // --theme (Req 35.3)
550    // -----------------------------------------------------------------------
551
552    #[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        // A user can pass both; --no-color wins at render time, but the theme
582        // preference still parses to its own field.
583        let cli = ok(&["vfa-tui", "--no-color", "--theme", "light"]);
584        assert!(cli.no_color);
585        assert_eq!(cli.theme, ThemePreference::Light);
586    }
587}