auditaur-cli 0.1.3

Command-line interface and MCP server for inspecting Auditaur local telemetry.
use anyhow::Result;
use auditaur_collector::exporter_sqlite::SqliteStore;
use auditaur_core::protocol::{DoctorCheck, DoctorReport};
use std::{
    fs,
    path::{Path, PathBuf},
};

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

pub fn run(db: Option<&Path>, json: bool) -> Result<()> {
    let report = report(db);
    print_report("Auditaur doctor", report, json)
}

pub fn tauri(path: Option<&Path>, json: bool) -> Result<()> {
    let root = path.map(PathBuf::from).unwrap_or(std::env::current_dir()?);
    let report = tauri_report(&root);
    print_report("Auditaur Tauri doctor", report, json)
}

pub fn report(db: Option<&Path>) -> DoctorReport {
    let mut checks = Vec::new();

    match db {
        Some(path) if path.exists() => {
            match SqliteStore::open(path).and_then(|store| {
                store.migrate()?;
                store.validate_schema()
            }) {
                Ok(()) => checks.push(DoctorCheck {
                    name: "sqlite-schema".to_string(),
                    ok: true,
                    message: format!("Database schema is valid: {}", path.display()),
                }),
                Err(error) => checks.push(DoctorCheck {
                    name: "sqlite-schema".to_string(),
                    ok: false,
                    message: format!("Database schema is invalid: {error}"),
                }),
            }
        }
        Some(path) => checks.push(DoctorCheck {
            name: "database-path".to_string(),
            ok: false,
            message: format!("Database path does not exist: {}", path.display()),
        }),
        None => match discovery::list_apps() {
            Ok(apps) if apps.is_empty() => checks.push(DoctorCheck {
                name: "discovery".to_string(),
                ok: true,
                message: "No discovery files found; pass --db to validate a specific database."
                    .to_string(),
            }),
            Ok(apps) => {
                let active = apps
                    .iter()
                    .filter(|app| {
                        app.status == DiscoveryStatus::Active
                            && app.database_readable
                            && app.schema_valid
                    })
                    .count();
                checks.push(DoctorCheck {
                    name: "discovery".to_string(),
                    ok: active > 0,
                    message: format!(
                        "Found {} discovery file(s), {active} active readable database(s).",
                        apps.len()
                    ),
                });
                for app in apps {
                    checks.push(DoctorCheck {
                        name: format!("discovery-db:{}", app.service_name),
                        ok: app.database_readable && app.schema_valid,
                        message: format!(
                            "{} database {} ({})",
                            if app.schema_valid { "Valid" } else { "Invalid" },
                            app.database_path,
                            app.stale_reason.unwrap_or_else(|| "active".to_string())
                        ),
                    });
                }
            }
            Err(error) => checks.push(DoctorCheck {
                name: "discovery".to_string(),
                ok: false,
                message: format!("Discovery check failed: {error}"),
            }),
        },
    }

    DoctorReport {
        ok: checks.iter().all(|check| check.ok),
        checks,
    }
}

fn tauri_report(root: &Path) -> DoctorReport {
    let app_dir = if root.join("src-tauri").is_dir() {
        root.join("src-tauri")
    } else {
        root.to_path_buf()
    };
    let mut checks = Vec::new();
    let cargo_toml = app_dir.join("Cargo.toml");
    let cargo_text = fs::read_to_string(&cargo_toml);
    checks.push(match &cargo_text {
        Ok(text) if text.contains("tauri-plugin-auditaur") => DoctorCheck {
            name: "tauri-plugin-dependency".to_string(),
            ok: true,
            message: format!("Found tauri-plugin-auditaur in {}", cargo_toml.display()),
        },
        Ok(_) => DoctorCheck {
            name: "tauri-plugin-dependency".to_string(),
            ok: false,
            message: format!("Missing tauri-plugin-auditaur in {}", cargo_toml.display()),
        },
        Err(error) => DoctorCheck {
            name: "tauri-plugin-dependency".to_string(),
            ok: false,
            message: format!("Could not read {}: {error}", cargo_toml.display()),
        },
    });

    let capabilities_dir = app_dir.join("capabilities");
    let capability_text = read_text_files(&capabilities_dir);
    checks.push(if capability_text.contains("auditaur:default") {
        DoctorCheck {
            name: "auditaur-permission".to_string(),
            ok: true,
            message: format!("Found auditaur:default under {}", capabilities_dir.display()),
        }
    } else {
        DoctorCheck {
            name: "auditaur-permission".to_string(),
            ok: false,
            message: format!(
                "Missing auditaur:default under {}; frontend export_otel_batch calls will be blocked.",
                capabilities_dir.display()
            ),
        }
    });

    let source_text = read_text_files(&app_dir.join("src"));
    checks.push(
        if source_text.contains("tauri_plugin_auditaur::Builder")
            || source_text.contains("tauri_plugin_auditaur::init")
            || source_text.contains("tauri-plugin-auditaur")
        {
            DoctorCheck {
                name: "plugin-registration".to_string(),
                ok: true,
                message: "Found Auditaur plugin registration reference in src.".to_string(),
            }
        } else {
            DoctorCheck {
                name: "plugin-registration".to_string(),
                ok: false,
                message: "No Auditaur plugin registration reference found in src.".to_string(),
            }
        },
    );

    checks.push(if source_text.contains("tracing_layer()") {
        DoctorCheck {
            name: "tracing-layer".to_string(),
            ok: true,
            message: "Found tauri_plugin_auditaur::tracing_layer usage.".to_string(),
        }
    } else {
        DoctorCheck {
            name: "tracing-layer".to_string(),
            ok: true,
            message: "No Auditaur tracing layer found; backend tracing capture is optional."
                .to_string(),
        }
    });

    DoctorReport {
        ok: checks.iter().all(|check| check.ok),
        checks,
    }
}

fn print_report(title: &str, report: DoctorReport, json: bool) -> Result<()> {
    if json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        println!("{}: {}", title, if report.ok { "ok" } else { "failed" });
        for check in report.checks {
            println!(
                "{} {} - {}",
                if check.ok { "ok" } else { "fail" },
                check.name,
                check.message
            );
        }
    }
    Ok(())
}

fn read_text_files(path: &Path) -> String {
    let mut text = String::new();
    read_text_files_into(path, &mut text);
    text
}

fn read_text_files_into(path: &Path, output: &mut String) {
    let Ok(metadata) = fs::metadata(path) else {
        return;
    };
    if metadata.is_file() {
        if is_text_candidate(path) {
            if let Ok(text) = fs::read_to_string(path) {
                output.push_str(&text);
                output.push('\n');
            }
        }
        return;
    }
    let Ok(entries) = fs::read_dir(path) else {
        return;
    };
    for entry in entries.flatten() {
        read_text_files_into(&entry.path(), output);
    }
}

fn is_text_candidate(path: &Path) -> bool {
    matches!(
        path.extension().and_then(|value| value.to_str()),
        Some("rs" | "toml" | "json" | "json5")
    )
}

#[cfg(test)]
mod tests {
    use super::report;
    use auditaur_collector::exporter_sqlite::SqliteStore;
    use tempfile::NamedTempFile;

    #[test]
    fn validates_sqlite_schema() {
        let db = NamedTempFile::new().unwrap();
        let store = SqliteStore::open(db.path()).unwrap();
        store.migrate().unwrap();
        drop(store);

        let report = report(Some(db.path()));

        assert!(report.ok);
        assert_eq!(report.checks[0].name, "sqlite-schema");
    }
}