auditaur-cli 0.1.2

Command-line interface and MCP server for inspecting Auditaur local telemetry.
use anyhow::Result;
use auditaur_core::protocol::{AppHealth, HealthCheck, HealthReport};

use crate::{
    discovery::{self, DiscoveryStatus},
    output::table_cell,
};

const EXPECTED_CAPABILITIES: &[&str] = &[
    "events",
    "frontend_errors",
    "ipc",
    "logs",
    "traces",
    "windows",
];

pub fn run(json: bool) -> Result<()> {
    let report = report();
    if json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        print_report(&report);
    }
    Ok(())
}

pub fn report() -> HealthReport {
    match discovery::list_apps() {
        Ok(apps) => {
            let app_health: Vec<_> = apps.into_iter().map(app_health).collect();
            let active_count = app_health
                .iter()
                .filter(|app| app.status == "active")
                .count();
            let unhealthy_active = app_health
                .iter()
                .filter(|app| app.status == "active" && !app.ok)
                .count();
            let ok = unhealthy_active == 0;
            let summary = match (active_count, unhealthy_active, app_health.len()) {
                (0, _, 0) => "No Auditaur apps discovered.".to_string(),
                (0, _, total) => {
                    format!("No active Auditaur apps; {total} stale app(s) discovered.")
                }
                (_, 0, _) => format!("{active_count} active Auditaur app(s) healthy."),
                _ => format!(
                    "{unhealthy_active} of {active_count} active Auditaur app(s) have health issues."
                ),
            };
            HealthReport {
                ok,
                summary,
                apps: app_health,
            }
        }
        Err(error) => HealthReport {
            ok: false,
            summary: format!("Discovery failed: {error}"),
            apps: Vec::new(),
        },
    }
}

fn app_health(app: discovery::DiscoveredApp) -> AppHealth {
    let mut checks = Vec::new();
    checks.push(HealthCheck {
        name: "heartbeat".to_string(),
        ok: app.status == DiscoveryStatus::Active,
        message: app
            .stale_reason
            .clone()
            .unwrap_or_else(|| "heartbeat is fresh".to_string()),
    });
    checks.push(HealthCheck {
        name: "database-readable".to_string(),
        ok: app.database_readable,
        message: if app.database_readable {
            format!("database is readable: {}", app.database_path)
        } else {
            format!("database is not readable: {}", app.database_path)
        },
    });
    checks.push(HealthCheck {
        name: "schema-valid".to_string(),
        ok: app.schema_valid,
        message: if app.schema_valid {
            "database schema is valid".to_string()
        } else {
            "database schema is invalid or could not be validated".to_string()
        },
    });
    let missing_capabilities = missing_capabilities(&app.capabilities);
    checks.push(HealthCheck {
        name: "collector-capabilities".to_string(),
        ok: missing_capabilities.is_empty(),
        message: if missing_capabilities.is_empty() {
            format!("collector capabilities: {}", app.capabilities.join(", "))
        } else {
            format!(
                "collector capability version skew; missing: {}",
                missing_capabilities.join(", ")
            )
        },
    });

    let status = match app.status {
        DiscoveryStatus::Active => "active",
        DiscoveryStatus::Stale => "stale",
    }
    .to_string();
    let ok = checks.iter().all(|check| check.ok);
    AppHealth {
        instance_id: app.instance_id,
        session_id: app.session_id,
        service_name: app.service_name,
        status,
        ok,
        checks,
    }
}

fn missing_capabilities(capabilities: &[String]) -> Vec<String> {
    EXPECTED_CAPABILITIES
        .iter()
        .filter(|expected| {
            !capabilities
                .iter()
                .any(|capability| capability.as_str() == **expected)
        })
        .map(|value| (*value).to_string())
        .collect()
}

fn print_report(report: &HealthReport) {
    println!(
        "Auditaur health: {}",
        if report.ok { "ok" } else { "failed" }
    );
    println!("{}", report.summary);
    if report.apps.is_empty() {
        return;
    }
    println!("APP\tSTATUS\tOK\tCHECKS");
    for app in &report.apps {
        println!(
            "{}\t{}\t{}\t{}",
            table_cell(&app.service_name, 48),
            app.status,
            app.ok,
            app.checks
                .iter()
                .filter(|check| !check.ok)
                .map(|check| check.name.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        );
    }
}