auditaur-cli 0.1.3

Command-line interface and MCP server for inspecting Auditaur local telemetry.
use std::{fs, path::PathBuf, time::Duration};

use anyhow::{anyhow, Result};
use auditaur_collector::exporter_sqlite::SqliteStore;
use auditaur_core::{discovery::DiscoveryFile, resolve_data_dir};
use serde::Serialize;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};

const STALE_AFTER: Duration = Duration::from_secs(30);

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredApp {
    pub instance_id: String,
    pub session_id: String,
    pub service_name: String,
    pub service_version: Option<String>,
    pub app_identifier: Option<String>,
    pub pid: u32,
    pub started_at: String,
    pub last_heartbeat_at: String,
    pub heartbeat_age_seconds: Option<i64>,
    pub status: DiscoveryStatus,
    pub capabilities: Vec<String>,
    pub database_path: String,
    pub database_readable: bool,
    pub schema_valid: bool,
    pub discovery_path: String,
    pub stale_reason: Option<String>,
    pub superseded_by_session_id: Option<String>,
    pub seconds_until_next_start: Option<i64>,
    pub churn_hint: Option<String>,
}

#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DiscoveryStatus {
    Active,
    Stale,
}

pub fn apps_dir() -> Result<PathBuf> {
    Ok(resolve_data_dir(None)?.join("apps"))
}

pub fn list_apps() -> Result<Vec<DiscoveredApp>> {
    let apps_dir = apps_dir()?;
    if !apps_dir.exists() {
        return Ok(Vec::new());
    }

    let mut apps = Vec::new();
    for entry in fs::read_dir(apps_dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|value| value.to_str()) != Some("json") {
            continue;
        }
        let bytes = fs::read(&path)?;
        let discovery: DiscoveryFile = serde_json::from_slice(&bytes)?;
        apps.push(app_from_discovery(discovery, path));
    }
    annotate_session_churn(&mut apps);
    apps.sort_by(|left, right| right.last_heartbeat_at.cmp(&left.last_heartbeat_at));
    Ok(apps)
}

pub fn resolve_db(db: Option<PathBuf>) -> Result<PathBuf> {
    if let Some(db) = db {
        return Ok(db);
    }

    let apps = list_apps()?;
    let usable: Vec<_> = apps
        .iter()
        .filter(|app| app.database_readable && app.schema_valid)
        .collect();
    let active: Vec<_> = usable
        .iter()
        .copied()
        .filter(|app| app.status == DiscoveryStatus::Active)
        .collect();

    match active.as_slice() {
        [app] => Ok(PathBuf::from(&app.database_path)),
        [] if usable.len() == 1 => Ok(PathBuf::from(&usable[0].database_path)),
        [] => Err(anyhow!(
            "No discoverable Auditaur session database found. Run `auditaur apps` or pass --db <path>."
        )),
        _ => Err(anyhow!(
            "Multiple active Auditaur sessions found. Run `auditaur apps` and pass --db <path>."
        )),
    }
}

fn app_from_discovery(discovery: DiscoveryFile, discovery_path: PathBuf) -> DiscoveredApp {
    let heartbeat_age_seconds = heartbeat_age(&discovery.last_heartbeat_at);
    let stale_reason = match heartbeat_age_seconds {
        Some(age) if age > i64::try_from(STALE_AFTER.as_secs()).unwrap_or(30) => {
            Some(format!("last heartbeat was {age}s ago"))
        }
        None => Some("last heartbeat timestamp could not be parsed".to_string()),
        _ => None,
    };
    let status = if stale_reason.is_some() {
        DiscoveryStatus::Stale
    } else {
        DiscoveryStatus::Active
    };
    let database_path = PathBuf::from(&discovery.database_path);
    let database_readable = database_path.is_file();
    let schema_valid = database_readable
        && SqliteStore::open(&database_path)
            .and_then(|store| {
                store.migrate()?;
                store.validate_schema()
            })
            .is_ok();

    DiscoveredApp {
        instance_id: discovery.instance_id,
        session_id: discovery.session_id,
        service_name: discovery.service_name,
        service_version: discovery.service_version,
        app_identifier: discovery.app_identifier,
        pid: discovery.pid,
        started_at: discovery.started_at,
        last_heartbeat_at: discovery.last_heartbeat_at,
        heartbeat_age_seconds,
        status,
        capabilities: discovery.capabilities,
        database_path: database_path.to_string_lossy().to_string(),
        database_readable,
        schema_valid,
        discovery_path: discovery_path.to_string_lossy().to_string(),
        stale_reason,
        superseded_by_session_id: None,
        seconds_until_next_start: None,
        churn_hint: None,
    }
}

fn heartbeat_age(value: &str) -> Option<i64> {
    let heartbeat = parse_timestamp(value)?;
    let age = OffsetDateTime::now_utc() - heartbeat;
    Some(age.whole_seconds().max(0))
}

fn annotate_session_churn(apps: &mut [DiscoveredApp]) {
    let snapshot = apps.to_vec();
    for app in apps
        .iter_mut()
        .filter(|app| app.status == DiscoveryStatus::Stale)
    {
        let Some(started_at) = parse_timestamp(&app.started_at) else {
            continue;
        };
        let Some((newer, newer_started_at)) = snapshot
            .iter()
            .filter(|candidate| candidate.instance_id != app.instance_id)
            .filter(|candidate| same_app_identity(app, candidate))
            .filter_map(|candidate| {
                let candidate_started_at = parse_timestamp(&candidate.started_at)?;
                (candidate_started_at > started_at).then_some((candidate, candidate_started_at))
            })
            .min_by_key(|(_, candidate_started_at)| *candidate_started_at)
        else {
            continue;
        };

        let seconds_until_next_start = (newer_started_at - started_at).whole_seconds().max(0);
        app.superseded_by_session_id = Some(newer.session_id.clone());
        app.seconds_until_next_start = Some(seconds_until_next_start);
        app.churn_hint = Some(if newer.status == DiscoveryStatus::Active {
            format!(
                "stale session appears superseded by active session {} started {}s later; likely app restart or Tauri dev watcher rebuild if this followed source edits",
                newer.session_id, seconds_until_next_start
            )
        } else {
            format!(
                "stale session has newer session {} started {}s later; inspect session chronology to distinguish app exit from repeated restarts",
                newer.session_id, seconds_until_next_start
            )
        });
    }
}

fn same_app_identity(left: &DiscoveredApp, right: &DiscoveredApp) -> bool {
    match (
        left.app_identifier.as_deref(),
        right.app_identifier.as_deref(),
    ) {
        (Some(left), Some(right)) => left == right,
        _ => left.service_name == right.service_name,
    }
}

fn parse_timestamp(value: &str) -> Option<OffsetDateTime> {
    OffsetDateTime::parse(value, &Rfc3339).ok()
}