use std::io::{self, IsTerminal};
use std::path::Path;
use anyhow::Result;
use crossterm::style::{Color, Stylize};
use super::data_safety::{
attachment_integrity_checks, database_integrity_report, ensure_integrity_ok,
};
use crate::config::{self as app_config, AppConfig};
use crate::render::print_json_pretty;
use crate::sync::sync_server_url_is_valid;
use crate::workspaces::resolve_active_workspace_with_database;
#[derive(serde::Serialize)]
pub(super) struct DoctorReport {
pub(super) sections: Vec<DoctorSection>,
}
impl DoctorReport {
pub(super) fn new() -> Self {
Self {
sections: Vec::new(),
}
}
pub(super) fn section(&mut self, title: &'static str) -> &mut DoctorSection {
self.sections.push(DoctorSection {
title,
rows: Vec::new(),
});
self.sections.last_mut().expect("section was pushed")
}
}
#[derive(serde::Serialize)]
pub(super) struct DoctorSection {
pub(super) title: &'static str,
pub(super) rows: Vec<DoctorRow>,
}
impl DoctorSection {
pub(super) fn check(&mut self, label: &'static str, ok: bool, value: impl Into<String>) {
self.rows.push(DoctorRow {
status: if ok {
DoctorStatus::Ok
} else {
DoctorStatus::Error
},
label,
value: value.into(),
});
}
pub(super) fn info(&mut self, label: &'static str, value: impl Into<String>) {
self.rows.push(DoctorRow {
status: DoctorStatus::Info,
label,
value: value.into(),
});
}
}
#[derive(serde::Serialize)]
pub(super) struct DoctorRow {
pub(super) status: DoctorStatus,
pub(super) label: &'static str,
pub(super) value: String,
}
#[derive(Clone, Copy, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum DoctorStatus {
Ok,
Error,
Info,
}
pub(super) struct DoctorRenderer {
styled: bool,
}
impl DoctorRenderer {
pub(super) fn auto() -> Self {
Self {
styled: io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(),
}
}
pub(super) fn print(&self, report: &DoctorReport) {
if self.styled {
println!(
"{}",
"aven doctor"
.with(Color::Rgb {
r: 45,
g: 174,
b: 135
})
.bold()
);
} else {
println!("aven doctor");
}
for section in &report.sections {
println!();
self.print_section(section.title);
let label_width = section
.rows
.iter()
.map(|row| row.label.chars().count())
.max()
.unwrap_or(0);
for row in §ion.rows {
self.print_row(row, label_width);
}
}
}
fn print_section(&self, title: &str) {
if self.styled {
println!("{}", title.with(Color::Cyan).bold());
} else {
println!("{title}");
println!("{}", "-".repeat(title.len()));
}
}
fn print_row(&self, row: &DoctorRow, label_width: usize) {
if self.styled {
self.print_styled_row(row, label_width);
} else {
let marker = row.status.marker();
println!(" {marker} {:<18} {}", row.label, row.value);
}
}
fn print_styled_row(&self, row: &DoctorRow, label_width: usize) {
let label = format!("{:<label_width$}", row.label);
println!(
" {} {} {}",
row.status.icon().with(row.status.color()).bold(),
label.with(row.status.label_color()),
row.value.as_str().with(Color::Rgb {
r: 150,
g: 150,
b: 150,
})
);
}
}
impl DoctorStatus {
fn marker(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Error => "!!",
Self::Info => "..",
}
}
fn icon(self) -> &'static str {
match self {
Self::Ok => "✓",
Self::Error => "✗",
Self::Info => "·",
}
}
fn color(self) -> Color {
match self {
Self::Ok => Color::Green,
Self::Error => Color::Red,
Self::Info => Color::DarkGrey,
}
}
fn label_color(self) -> Color {
match self {
Self::Ok | Self::Error => Color::White,
Self::Info => Color::Grey,
}
}
}
fn format_optional_i64(value: Option<i64>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string())
}
pub(crate) async fn cmd_doctor(
database: &aven_core::db::Database,
config: &AppConfig,
db_path: &Path,
db_flag_set: bool,
workspace_flag: Option<&str>,
integrity: bool,
json: bool,
) -> Result<()> {
let config_file = app_config::config_file_path();
let db_source = if db_flag_set {
"--db"
} else if app_config::debug_db_path_from_env().is_some() {
"AVEN_DEV_DB"
} else if std::env::var_os("AVEN_DB").is_some() {
"AVEN_DB"
} else if config.local.db_path.is_some() {
"config local.db_path"
} else {
"default"
};
let client_id = database.meta("client_id").await?;
let sync_cursor = database.meta("sync_cursor").await?;
let local_seq = database.meta("local_seq").await?;
let pinned_server = database.meta("sync_server_url").await?;
let cwd = std::env::current_dir()?;
let workspace =
resolve_active_workspace_with_database(database, workspace_flag, config, &cwd).await;
let counts = match &workspace {
Ok(workspace) => Some(database.workspace_task_counts(&workspace.id).await?),
Err(_) => None,
};
let sync_history = database.sync_history_stats().await?;
let unresolved_conflicts = database.unresolved_conflict_count().await?;
let sync_server = app_config::resolve_sync_server(None, config);
let wake_addr = config.wake_addr();
let mut report = DoctorReport::new();
let config_section = report.section("Configuration");
match config_file {
Ok(path) if path.exists() => {
config_section.check("config file", true, path.display().to_string());
}
Ok(path) => {
config_section.info(
"config file",
format!("{} (using defaults)", path.display()),
);
}
Err(error) => {
config_section.check("config file", false, format!("{error:#}"));
}
}
config_section.info("database source", db_source);
config_section.info("database path", db_path.display().to_string());
let database_section = report.section("Database");
database_section.check("sqlite", true, "opened successfully");
database_section.check(
"client id",
client_id.is_some(),
client_id.as_deref().unwrap_or("missing"),
);
database_section.info("sync cursor", sync_cursor.as_deref().unwrap_or("missing"));
database_section.info("local sequence", local_seq.as_deref().unwrap_or("missing"));
database_section.info("pinned server", pinned_server.as_deref().unwrap_or("none"));
database_section.info("change rows", sync_history.total_change_rows.to_string());
database_section.info(
"pending changes",
sync_history.pending_change_rows.to_string(),
);
database_section.info(
"synced changes",
sync_history.synced_change_rows.to_string(),
);
database_section.info(
"min server_seq",
format_optional_i64(sync_history.min_server_seq),
);
database_section.info(
"max server_seq",
format_optional_i64(sync_history.max_server_seq),
);
database_section.info("payload bytes", sync_history.payload_bytes.to_string());
database_section.info("conflicts", unresolved_conflicts.to_string());
let workspace_section = report.section("Workspace");
match workspace {
Ok(workspace) => {
workspace_section.check(
"active workspace",
true,
format!("{} ({})", workspace.name, workspace.key),
);
if let Some(counts) = counts {
workspace_section.info(
"tasks",
format!("{} visible, {} total", counts.visible, counts.total),
);
}
}
Err(error) => {
workspace_section.check("active workspace", false, format!("{error:#}"));
}
}
let sync_section = report.section("Sync");
sync_section.info("enabled", if config.sync.enabled { "yes" } else { "no" });
match sync_server {
Ok(server) => {
sync_section.check("server", sync_server_url_is_valid(&server), &server);
if let Some(pinned) = pinned_server.as_deref() {
let normalized = server.trim_end_matches('/');
sync_section.check(
"server match",
pinned == normalized,
format!("pinned={pinned} configured={normalized}"),
);
}
}
Err(error) => {
if config.sync.enabled {
sync_section.check("server", false, format!("{error:#}"));
} else {
sync_section.info("server", "not configured");
}
}
}
match config.sync.server_url.as_deref() {
Some(server) => {
sync_section.check("daemon server", sync_server_url_is_valid(server), server)
}
None if config.sync.enabled => sync_section.check("daemon server", false, "not configured"),
None => sync_section.info("daemon server", "not configured"),
}
sync_section.info(
"auth token",
if config.sync_auth_token().is_some() {
"configured"
} else {
"not configured"
},
);
sync_section.info(
"interval",
format!("{} seconds", config.sync_interval_seconds()),
);
match wake_addr {
Ok(addr) => sync_section.check("daemon wake", true, addr.to_string()),
Err(error) => sync_section.check("daemon wake", false, format!("{error:#}")),
}
let daemon_status = crate::daemon::status_snapshot()?;
let daemon_section = report.section("Daemon");
daemon_section.info(
"installed",
if daemon_status.installed { "yes" } else { "no" },
);
match daemon_status.loaded {
Some(loaded) => daemon_section.check("loaded", loaded, if loaded { "yes" } else { "no" }),
None => daemon_section.info("loaded", "unknown"),
}
daemon_section.info("plist", daemon_status.plist_path.display().to_string());
daemon_section.info(
"program",
daemon_status
.program
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "missing".to_string()),
);
daemon_section.info(
"current exe",
daemon_status.current_executable.display().to_string(),
);
match daemon_status.program_matches_current {
Some(matches) => {
daemon_section.check("program match", matches, if matches { "yes" } else { "no" })
}
None => daemon_section.info("program match", "unknown"),
}
let blob_dir = app_config::resolve_blob_dir(db_path, config)?;
let lifecycle = database
.attachment_lifecycle_report(&blob_dir, config.local.attachment_lifecycle.policy())
.await?;
let lifecycle_section = report.section("Attachment lifecycle");
lifecycle_section.info(
"referenced",
format!(
"count={} bytes={}",
lifecycle.referenced.count, lifecycle.referenced.bytes
),
);
lifecycle_section.info(
"protected",
format!(
"count={} bytes={}",
lifecycle.protected.count, lifecycle.protected.bytes
),
);
lifecycle_section.info(
"grace period",
format!(
"count={} bytes={}",
lifecycle.grace_period.count, lifecycle.grace_period.bytes
),
);
lifecycle_section.info(
"eligible",
format!(
"count={} bytes={}",
lifecycle.eligible.count, lifecycle.eligible.bytes
),
);
lifecycle_section.info(
"staging",
format!(
"count={} bytes={}",
lifecycle.staging.count, lifecycle.staging.bytes
),
);
lifecycle_section.info(
"trash",
format!(
"count={} bytes={}",
lifecycle.trash.count, lifecycle.trash.bytes
),
);
lifecycle_section.info(
"reservations",
format!(
"count={} bytes={}",
lifecycle.reservations.count, lifecycle.reservations.bytes
),
);
lifecycle_section.check(
"quota",
lifecycle.quota.bytes
<= u64::try_from(config.local.attachment_lifecycle.quota_bytes).unwrap_or(0),
format!(
"count={} bytes={} limit={}",
lifecycle.quota.count,
lifecycle.quota.bytes,
config.local.attachment_lifecycle.quota_bytes,
),
);
lifecycle_section.check(
"inconsistencies",
lifecycle.inconsistencies.count == 0,
format!(
"count={} bytes={}",
lifecycle.inconsistencies.count, lifecycle.inconsistencies.bytes
),
);
let attachment_checks = attachment_integrity_checks(database, &blob_dir, integrity).await?;
if !integrity {
let attachment_section = report.section("Attachments");
for check in &attachment_checks {
attachment_section.check(check.label, check.ok, &check.value);
}
}
if integrity {
let integrity_report = database_integrity_report(database).await?;
let integrity_section = report.section("Integrity");
integrity_section.check(
"quick check",
integrity_report.quick_check_ok,
&integrity_report.quick_check_value,
);
for check in &integrity_report.checks {
integrity_section.check(check.label, check.ok, &check.value);
}
for check in &attachment_checks {
integrity_section.check(check.label, check.ok, &check.value);
}
let mut combined_report = integrity_report.clone();
combined_report.checks.extend(attachment_checks);
if let Err(error) = ensure_integrity_ok(&combined_report) {
integrity_section.check("result", false, format!("{error:#}"));
}
}
if json {
print_json_pretty(&report)?;
} else {
DoctorRenderer::auto().print(&report);
}
Ok(())
}