#![deny(warnings)]
use vfa_tui::{app, catalog, federation, headless, logging, persistence, policy, ui, workspace};
use std::path::PathBuf;
use clap::Parser;
use vfa_tui::cli::Cli;
use vfa_tui::headless::reporter::HeadlessReporter;
#[derive(Debug, PartialEq, Eq)]
pub enum Mode {
ValidateConfig,
ExportAudit,
Headless,
Tui,
}
pub fn select_mode(cli: &Cli) -> Mode {
if cli.validate_config {
return Mode::ValidateConfig;
}
if cli.export_audit.is_some() {
return Mode::ExportAudit;
}
if cli.report.is_some() {
return Mode::Headless;
}
Mode::Tui
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut cli = Cli::parse();
cli.expand_home_paths();
let mode = select_mode(&cli);
let workspace_root = match workspace::detect::detect_workspace(cli.workspace.as_deref()) {
Ok(p) => p,
Err(e) => {
eprintln!("[vfa-tui] workspace detection failed: {e}");
std::process::exit(2);
}
};
let session_id = uuid::Uuid::new_v4();
let log_file = match mode {
Mode::Headless | Mode::ValidateConfig | Mode::ExportAudit => {
if cli.quiet {
None
} else {
cli.log_file.as_deref()
}
}
Mode::Tui => cli.log_file.as_deref(),
};
let log_level_str = format!("{:?}", cli.log_level).to_lowercase();
logging::audit::init_logging(log_file, &log_level_str, session_id)?;
match mode {
Mode::ValidateConfig => {
run_validate_config(&cli, &workspace_root);
}
Mode::ExportAudit => {
run_export_audit(&cli, &workspace_root);
}
Mode::Headless => {
let format = cli.output_format();
let quiet = cli.quiet;
let reporter = HeadlessReporter::new(format, quiet);
let (_value, exit_code) = reporter.run(&cli, &workspace_root);
if let Some(parent) = std::path::Path::new(&cli.index_path).parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
if let Ok(mgr) = persistence::index::IndexManager::open(&cli.index_path) {
let _ =
headless::reporter::record_headless_audit(&mgr, &cli.report_types(), exit_code);
}
std::process::exit(exit_code as i32);
}
Mode::Tui => {
ui::install_panic_hook();
run_tui_async(&cli, &workspace_root).await?;
}
}
Ok(())
}
fn run_validate_config(cli: &Cli, workspace_root: &std::path::Path) {
let mut errors: Vec<String> = Vec::new();
let registry_path = PathBuf::from(&cli.registry);
if registry_path.exists() {
match federation::registry::WorkspaceRegistry::load(®istry_path) {
Ok(federation::registry::LoadResult::Loaded(reg)) => {
let validation_errors = reg.validate();
if !validation_errors.is_empty() {
for err in validation_errors {
errors.push(format!(
"registry {}: semantic validation error: {err}",
registry_path.display()
));
}
}
}
Ok(federation::registry::LoadResult::NotFound(_)) => {
}
Err(e) => {
errors.push(format!(
"registry {}: parse/load error: {e}",
registry_path.display()
));
}
}
} else if !cli.quiet {
eprintln!(
"[vfa-tui] registry not found at {} (skipping validation)",
registry_path.display()
);
}
let policy_path = PathBuf::from(&cli.policies);
if policy_path.exists() {
match policy::parser::load(&policy_path) {
Ok(_) => {}
Err(e) => {
errors.push(format!("policies {}: {e}", policy_path.display()));
}
}
} else if !cli.quiet {
eprintln!(
"[vfa-tui] policies not found at {} (skipping validation)",
policy_path.display()
);
}
let catalog_dir = workspace_root.join("catalog");
if !catalog_dir.exists() {
errors.push(format!(
"catalog directory not found at {}",
catalog_dir.display()
));
}
if errors.is_empty() {
if !cli.quiet {
eprintln!("[vfa-tui] configuration valid");
}
std::process::exit(0);
} else {
for e in &errors {
eprintln!("[vfa-tui] config error: {e}");
}
std::process::exit(2);
}
}
fn run_export_audit(cli: &Cli, _workspace_root: &std::path::Path) {
let parts = cli
.export_audit
.as_ref()
.expect("ExportAudit mode implies export_audit is Some");
if parts.len() < 2 {
eprintln!("[vfa-tui] --export-audit requires FORMAT and PATH");
std::process::exit(2);
}
let format = parts[0].as_str();
let out_path = std::path::Path::new(&parts[1]);
let index_path = &cli.index_path;
let mgr = match persistence::index::IndexManager::open(index_path) {
Ok(m) => m,
Err(e) => {
eprintln!("[vfa-tui] failed to open index at {index_path}: {e}");
std::process::exit(2);
}
};
let logger = match persistence::audit::AuditLogger::from_manager(&mgr) {
Ok(l) => l,
Err(e) => {
eprintln!("[vfa-tui] failed to initialise audit logger: {e}");
std::process::exit(2);
}
};
match logger.export_audit(format, out_path) {
Ok(()) => {
if !cli.quiet {
eprintln!(
"[vfa-tui] audit log exported ({format}) to {}",
out_path.display()
);
}
std::process::exit(0);
}
Err(e) => {
eprintln!("[vfa-tui] export failed: {e}");
std::process::exit(2);
}
}
}
use tokio::sync::mpsc;
use tokio::time::interval;
async fn run_tui_async(cli: &Cli, workspace_root: &std::path::Path) -> anyhow::Result<()> {
let catalog = catalog::store::CatalogStore::load(workspace_root);
if !catalog.load_errors.is_empty() {
for err in &catalog.load_errors {
tracing::warn!(%err, "catalog load warning");
}
}
let mut terminal_mgr = ui::TerminalManager::new()?;
let session_id = uuid::Uuid::new_v4();
let no_color = cli.is_no_color();
let theme_mode = ui::theme::resolve_theme(cli.theme, false);
let mut app = app::App::new(catalog, workspace_root.to_path_buf(), session_id, no_color);
app.theme_mode = theme_mode;
let (tx_event, mut rx_event) = mpsc::channel(256);
let _event_task = tokio::task::spawn_blocking({
let tx = tx_event.clone();
move || loop {
if let Ok(true) = crossterm::event::poll(std::time::Duration::from_millis(50)) {
if let Ok(evt) = crossterm::event::read() {
if tx.blocking_send(evt).is_err() {
break;
}
}
}
}
});
let mut tick_interval = interval(std::time::Duration::from_millis(250));
let catalog_dir = workspace_root.join("catalog");
let registry_pb = std::path::PathBuf::from(&cli.registry);
let registry_opt = if registry_pb.exists() {
Some(registry_pb.as_path())
} else {
None
};
let (_watch_handle, mut rx_watch) =
match catalog::watcher::spawn_watcher(&catalog_dir, registry_opt, &[]) {
Ok((handle, rx)) => (Some(handle), Some(rx)),
Err(e) => {
tracing::warn!(%e, "filesystem watcher unavailable; live reload disabled");
(None, None)
}
};
loop {
let mut event_occurred = false;
while let Ok(evt) = rx_event.try_recv() {
use crossterm::event::Event;
match evt {
Event::Key(key) => {
app.handle_key_event(key);
event_occurred = true;
}
Event::Resize(_width, _height) => {
event_occurred = true;
}
_ => {}
}
}
if event_occurred {
app.mark_dirty();
}
if app.dirty {
terminal_mgr.draw(|frame| app.render(frame))?;
app.dirty = false;
}
if app.should_quit {
break;
}
tokio::select! {
_ = tick_interval.tick() => {
app.tick();
app.mark_dirty();
}
maybe_ev = async {
match rx_watch.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => {
if let Some(event) = maybe_ev {
match event {
catalog::watcher::WatcherEvent::Catalog(path) => {
if path.exists() {
let _ = app.reload_catalog_file(&path);
} else {
app.reload_catalog();
}
}
catalog::watcher::WatcherEvent::Registry
| catalog::watcher::WatcherEvent::Workspace(_) => {
app.reload_catalog();
}
}
}
}
}
}
drop(tx_event);
terminal_mgr.restore()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("parse")
}
#[test]
fn default_flags_select_tui() {
let cli = parse(&["vfa-tui"]);
assert_eq!(select_mode(&cli), Mode::Tui);
}
#[test]
fn validate_config_flag_selects_validate_config() {
let cli = parse(&["vfa-tui", "--validate-config"]);
assert_eq!(select_mode(&cli), Mode::ValidateConfig);
}
#[test]
fn export_audit_flag_selects_export_audit() {
let cli = parse(&["vfa-tui", "--export-audit", "json", "/tmp/a.json"]);
assert_eq!(select_mode(&cli), Mode::ExportAudit);
}
#[test]
fn report_flag_selects_headless() {
let cli = parse(&["vfa-tui", "--report", "summary"]);
assert_eq!(select_mode(&cli), Mode::Headless);
}
#[test]
fn report_coverage_selects_headless() {
let cli = parse(&["vfa-tui", "--report", "coverage"]);
assert_eq!(select_mode(&cli), Mode::Headless);
}
#[test]
fn report_all_selects_headless() {
let cli = parse(&["vfa-tui", "--report", "all"]);
assert_eq!(select_mode(&cli), Mode::Headless);
}
#[test]
fn validate_config_has_priority_over_report() {
let cli = parse(&["vfa-tui", "--validate-config", "--report", "summary"]);
assert_eq!(select_mode(&cli), Mode::ValidateConfig);
}
#[test]
fn export_audit_has_priority_over_report() {
let cli = parse(&[
"vfa-tui",
"--export-audit",
"json",
"/tmp/a.json",
"--report",
"summary",
]);
assert_eq!(select_mode(&cli), Mode::ExportAudit);
}
#[test]
fn no_color_accessible_via_is_no_color() {
let cli = parse(&["vfa-tui", "--no-color"]);
assert!(cli.is_no_color());
}
#[test]
fn web_flag_still_selects_tui() {
let cli = parse(&["vfa-tui", "--web"]);
assert_eq!(select_mode(&cli), Mode::Tui);
}
}