Ctrl K
ComunidadGuías…

crates/tui/src/doctor.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Structural, offline-by-default diagnostics shared by doctor renderers.

use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use serde::Serialize;

/// Canonical user-scoped paths reported by both human and JSON doctor output.
///
/// Resolution is read-only: this type does not construct managers, create
/// directories, or trigger legacy migration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct DoctorPathReport {
    pub(crate) home: PathBuf,
    pub(crate) config: PathBuf,
    pub(crate) settings: PathBuf,
    pub(crate) sessions: PathBuf,
    pub(crate) logs: PathBuf,
    pub(crate) automations: PathBuf,
    pub(crate) task_manager_root: PathBuf,
    pub(crate) task_manager_tasks: PathBuf,
    pub(crate) task_manager_artifacts: PathBuf,
    pub(crate) runtime_store: PathBuf,
    pub(crate) runtime_events: PathBuf,
    pub(crate) personal_fleet_definitions: PathBuf,
    pub(crate) personal_fleet_agents: PathBuf,
    pub(crate) secrets: PathBuf,
}

impl DoctorPathReport {
    pub(crate) fn resolve(config_override: Option<&Path>) -> Result<Self> {
        let home = codewhale_paths::codewhale_home()
            .map_err(anyhow::Error::new)?
            .context("could not resolve the canonical Codewhale state root")?;
        let config = match config_override {
            Some(path) => codewhale_config::resolve_config_path(Some(path.to_path_buf()))
                .context("could not normalize the explicit config path")?,
            None => codewhale_config::resolve_config_path(None)
                .unwrap_or_else(|_| home.join(codewhale_config::CONFIG_FILE_NAME)),
        };
        let settings = codewhale_runtime::settings::Settings::path()
            .context("could not resolve the canonical settings path")?;
        let sessions = codewhale_config::resolve_state_dir("sessions")
            .context("could not resolve the sessions path")?;
        let logs = codewhale_runtime::runtime_log::log_directory()
            .context("could not resolve the runtime log directory")?;
        let automations = codewhale_engine::automation_manager::default_automations_dir();
        let task_manager_root = codewhale_engine::task_manager::default_tasks_dir();
        let task_manager_tasks = task_manager_root.join("tasks");
        let task_manager_artifacts = task_manager_root.join("artifacts");
        let runtime_config =
            codewhale_engine::runtime_threads::manager::RuntimeThreadManagerConfig::from_task_data_dir(
                task_manager_root.clone(),
            );
        let runtime_store = runtime_config.data_dir;
        let runtime_events = runtime_store.join("events");
        let personal_fleet_definitions =
            codewhale_engine::fleet::exact::personal_fleet_definitions_dir()
                .context("could not resolve the personal Fleet definitions directory")?;
        let personal_fleet_agents = codewhale_engine::fleet::profile::personal_agent_profile_dir()
            .context("could not resolve the personal Fleet agent directory")?;
        let (secrets, _) = codewhale_secrets::FileKeyringStore::default_paths_read_only()
            .context("could not resolve the file secret backend path")?;
        Ok(Self {
            home,
            config,
            settings,
            sessions,
            logs,
            automations,
            task_manager_root,
            task_manager_tasks,
            task_manager_artifacts,
            runtime_store,
            runtime_events,
            personal_fleet_definitions,
            personal_fleet_agents,
            secrets,
        })
    }

    pub(crate) fn entries(&self) -> [(&'static str, &Path); 14] {
        [
            ("home", self.home.as_path()),
            ("config", self.config.as_path()),
            ("settings", self.settings.as_path()),
            ("sessions", self.sessions.as_path()),
            ("logs", self.logs.as_path()),
            ("automations", self.automations.as_path()),
            ("task_manager_root", self.task_manager_root.as_path()),
            ("task_manager_tasks", self.task_manager_tasks.as_path()),
            (
                "task_manager_artifacts",
                self.task_manager_artifacts.as_path(),
            ),
            ("runtime_store", self.runtime_store.as_path()),
            ("runtime_events", self.runtime_events.as_path()),
            (
                "personal_fleet_definitions",
                self.personal_fleet_definitions.as_path(),
            ),
            (
                "personal_fleet_agents",
                self.personal_fleet_agents.as_path(),
            ),
            ("secrets", self.secrets.as_path()),
        ]
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) struct SessionStoreCounts {
    pub(crate) saved: usize,
    pub(crate) checkpoints: usize,
}

pub(crate) fn session_store_counts(sessions: &Path) -> Result<Option<SessionStoreCounts>, String> {
    let Some(manager) =
        codewhale_engine::session_manager::SessionManager::open_read_only(sessions.to_path_buf())
            .map_err(|error| error.to_string())?
    else {
        return Ok(None);
    };
    Ok(Some(SessionStoreCounts {
        saved: manager
            .saved_session_count()
            .map_err(|error| error.to_string())?,
        checkpoints: manager
            .list_checkpoints()
            .map_err(|error| error.to_string())?
            .len(),
    }))
}

/// Render structural secret-backend facts for the human doctor report.
///
/// The input type cannot carry secret values, keeping this renderer safe by
/// construction.
pub(crate) fn secret_backend_human_lines(
    diagnostic: &codewhale_secrets::SecretBackendDiagnostic,
) -> Vec<String> {
    use codewhale_secrets::{
        SecretBackendDiagnosticKind, SecretBackendInspection, SecretBackendPresence,
    };

    let presence = |value| match value {
        SecretBackendPresence::Present => "present",
        SecretBackendPresence::Absent => "absent",
        SecretBackendPresence::Unknown => "unknown",
    };
    let inspection = match diagnostic.inspection {
        SecretBackendInspection::MetadataOnly => "metadata_only",
        SecretBackendInspection::NotProbed => "not_probed",
    };
    let mut lines = match diagnostic.backend {
        SecretBackendDiagnosticKind::File => vec![
            "backend: file".to_string(),
            format!("presence: {} ({inspection})", presence(diagnostic.presence)),
        ],
        SecretBackendDiagnosticKind::System => vec![
            "backend: system".to_string(),
            "status: unknown (not_probed)".to_string(),
        ],
        SecretBackendDiagnosticKind::Unknown => vec![
            "backend: unknown".to_string(),
            "status: unknown (not_probed; unsupported configuration)".to_string(),
        ],
    };
    if let Some(path) = diagnostic.path.as_deref() {
        lines.push(format!("path: {}", path.display()));
    }
    if let Some(path) = diagnostic.legacy_path.as_deref() {
        lines.push(format!(
            "legacy_path: {} ({}, {inspection})",
            path.display(),
            presence(diagnostic.legacy_presence)
        ));
    }
    lines.push("No credential-store values were read or printed by this check.".to_string());
    lines
}

/// Report key names — never values — for config entries whose value is
/// shaped like a bearer credential. `config.toml` is plain text, not a
/// secret store; doctor warns so tokens migrate to the secret backend
/// (morning-report issue: a plaintext OAuth token sat beside a `[redacted]`
/// sibling entry).
pub(crate) fn config_credential_shaped_keys(raw: &str) -> Vec<String> {
    fn strong_shape(value: &str) -> bool {
        const PREFIXES: [&str; 9] = [
            "sk-",
            "sk_",
            "xai-",
            "ghp_",
            "gho_",
            "github_pat_",
            "xoxb-",
            "xoxp-",
            "eyJ",
        ];
        value.len() >= 20 && PREFIXES.iter().any(|prefix| value.starts_with(prefix))
    }
    fn suspect_key(key: &str) -> bool {
        let key = key.to_ascii_lowercase();
        [
            "token",
            "secret",
            "password",
            "credential",
            "api_key",
            "apikey",
            "access_key",
        ]
        .iter()
        .any(|needle| key.contains(needle))
    }
    fn random_shape(value: &str) -> bool {
        value.len() >= 24
            && value
                .chars()
                .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
            && value.chars().any(|ch| ch.is_ascii_digit())
            && value.chars().any(|ch| ch.is_ascii_alphabetic())
    }

    let mut flagged: Vec<String> = Vec::new();
    for line in raw.lines() {
        let line = line.trim();
        if line.starts_with('#') {
            continue;
        }
        let Some((key, value)) = line.split_once('=') else {
            continue;
        };
        let key = key.trim().trim_matches('"');
        let value = value.trim();
        let Some(value) = value
            .strip_prefix('"')
            .and_then(|value| value.strip_suffix('"'))
        else {
            continue;
        };
        if value.is_empty() || value.eq_ignore_ascii_case("[redacted]") {
            continue;
        }
        if (strong_shape(value) || (suspect_key(key) && random_shape(value)))
            && !flagged.iter().any(|existing| existing == key)
        {
            flagged.push(key.to_string());
        }
    }
    flagged
}

/// Explicit live operations requested for one doctor invocation.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct DoctorProbeRequest {
    pub(crate) check_updates: bool,
    pub(crate) probe_api: bool,
    pub(crate) probe_local: bool,
    pub(crate) probe_mcp: bool,
    pub(crate) probe_search: bool,
}

impl DoctorProbeRequest {
    /// Whether a release service may be contacted.
    pub(crate) fn should_check_updates(self) -> bool {
        self.check_updates
    }

    /// Whether the configured provider endpoint may be contacted.
    ///
    /// Hosted and local endpoints have separate opt-ins because a local probe
    /// can wake a desktop-managed daemon.
    pub(crate) fn should_probe_api(self, endpoint_is_local: bool) -> bool {
        if endpoint_is_local {
            self.probe_local
        } else {
            self.probe_api
        }
    }

    /// Whether configured MCP processes may be started and contacted.
    pub(crate) fn should_probe_mcp(self) -> bool {
        self.probe_mcp
    }

    /// Whether the selected web-search provider authority may be contacted.
    pub(crate) fn should_probe_search(self) -> bool {
        self.probe_search
    }
}

const SEARCH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);

/// Bounded, credential-free reachability result for the configured search
/// provider. This intentionally cannot represent a successful search or valid
/// authentication: a HEAD response proves transport reachability only.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DoctorSearchProbeReport {
    NotChecked,
    FeatureDisabled,
    ConfigurationError(codewhale_engine::tools::web_search::SearchProbeTargetError),
    PolicyDenied {
        authority: String,
    },
    PolicyApprovalRequired {
        authority: String,
    },
    Reachable {
        authority: String,
        status: u16,
    },
    Unreachable {
        authority: String,
        failure: DoctorSearchProbeFailure,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DoctorSearchProbeFailure {
    Timeout,
    Connection,
    Request,
}

/// Probe the selected search provider without reading credentials, issuing a
/// query, following redirects, or writing a network-policy audit receipt.
pub(crate) async fn doctor_search_probe(
    config: &codewhale_runtime::config::Config,
    probes: DoctorProbeRequest,
) -> DoctorSearchProbeReport {
    use codewhale_net::network_policy::{Decision, NetworkPolicyDecider};

    if !probes.should_probe_search() {
        return DoctorSearchProbeReport::NotChecked;
    }
    if !config
        .features()
        .enabled(codewhale_runtime::features::Feature::WebSearch)
    {
        return DoctorSearchProbeReport::FeatureDisabled;
    }

    let provider = config.search_provider();
    let base_url = config
        .search
        .as_ref()
        .and_then(|search| search.base_url.as_deref());
    let target = match codewhale_engine::tools::web_search::search_probe_target(provider, base_url)
    {
        Ok(target) => target,
        Err(error) => return DoctorSearchProbeReport::ConfigurationError(error),
    };
    let authority = codewhale_config::structural_url_authority(target.url.as_str());

    if let Some(policy) = config.network.clone().map(|network| {
        // Doctor is a read-only diagnostic. Evaluate the same typed policy as
        // web_search, but do not attach the runtime audit writer.
        NetworkPolicyDecider::new(
            codewhale_net::network_policy::NetworkPolicy::from(network),
            None,
        )
    }) {
        match policy.evaluate(&target.host, "web_search") {
            Decision::Allow => {}
            Decision::Deny => return DoctorSearchProbeReport::PolicyDenied { authority },
            Decision::Prompt => {
                return DoctorSearchProbeReport::PolicyApprovalRequired { authority };
            }
        }
    }

    let client = match codewhale_release::tls::reqwest_client_builder()
        .redirect(reqwest::redirect::Policy::none())
        .connect_timeout(SEARCH_PROBE_TIMEOUT)
        .timeout(SEARCH_PROBE_TIMEOUT)
        .user_agent(concat!("codewhale-doctor/", env!("CARGO_PKG_VERSION")))
        .build()
    {
        Ok(client) => client,
        Err(_) => {
            return DoctorSearchProbeReport::Unreachable {
                authority,
                failure: DoctorSearchProbeFailure::Request,
            };
        }
    };

    match client.head(target.url).send().await {
        Ok(response) => DoctorSearchProbeReport::Reachable {
            authority,
            status: response.status().as_u16(),
        },
        Err(error) => {
            let failure = if error.is_timeout() {
                DoctorSearchProbeFailure::Timeout
            } else if error.is_connect() {
                DoctorSearchProbeFailure::Connection
            } else {
                DoctorSearchProbeFailure::Request
            };
            DoctorSearchProbeReport::Unreachable { authority, failure }
        }
    }
}

/// Plain, bounded doctor copy for a search reachability result.
pub(crate) fn doctor_search_probe_lines(report: &DoctorSearchProbeReport) -> Vec<String> {
    use codewhale_engine::tools::web_search::SearchProbeTargetError;

    match report {
        DoctorSearchProbeReport::NotChecked => vec![
            "· not checked (offline default)".to_string(),
            "Run `codewhale doctor --probe-search` to test the selected provider authority."
                .to_string(),
        ],
        DoctorSearchProbeReport::FeatureDisabled => vec![
            "· not checked because the web_search feature is disabled".to_string(),
            "Enable web_search before probing its provider.".to_string(),
        ],
        DoctorSearchProbeReport::ConfigurationError(error) => {
            let detail = match error {
                SearchProbeTargetError::Missing => {
                    "SearXNG requires an explicit [search] base_url"
                }
                SearchProbeTargetError::Unsupported => {
                    "[search] base_url is supported only for DuckDuckGo-compatible or SearXNG providers"
                }
                SearchProbeTargetError::Invalid => {
                    "the configured [search] base_url is not a valid HTTP(S) authority"
                }
            };
            vec![format!("! not probed: {detail}")]
        }
        DoctorSearchProbeReport::PolicyDenied { authority } => vec![
            format!("! {authority} blocked by configured network policy"),
            "The same policy blocks web_search; update /network only if this authority should be allowed."
                .to_string(),
        ],
        DoctorSearchProbeReport::PolicyApprovalRequired { authority } => vec![
            format!("! {authority} requires network approval"),
            "Allow the authority with /network, then rerun this probe.".to_string(),
        ],
        DoctorSearchProbeReport::Reachable { authority, status } => vec![
            format!("✓ {authority} responded (HTTP {status})"),
            "Transport only; authentication and search results were not tested.".to_string(),
        ],
        DoctorSearchProbeReport::Unreachable { authority, failure } => {
            let detail = match failure {
                DoctorSearchProbeFailure::Timeout => "timed out",
                DoctorSearchProbeFailure::Connection => "connection, DNS, or TLS setup failed",
                DoctorSearchProbeFailure::Request => "request failed",
            };
            vec![
                format!("✗ {authority} {detail}"),
                "No search API key or query was sent; provider error details were omitted."
                    .to_string(),
            ]
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum DoctorUpdateReport {
    NotChecked,
    UpdateAvailable { latest: String },
    UpToDate { latest: String },
    CurrentNewer { latest: String },
    ReleaseMetadataInvalid,
    ReleaseCheckFailed,
}

fn doctor_update_report<E>(
    current_version: &str,
    latest_result: Result<String, E>,
) -> DoctorUpdateReport {
    let Ok(raw_latest) = latest_result else {
        return DoctorUpdateReport::ReleaseCheckFailed;
    };
    let Some(latest) = doctor_safe_release_tag(&raw_latest) else {
        return DoctorUpdateReport::ReleaseMetadataInvalid;
    };
    match codewhale_release::compare_release_versions(current_version, &latest) {
        Ok(std::cmp::Ordering::Less) => DoctorUpdateReport::UpdateAvailable { latest },
        Ok(std::cmp::Ordering::Equal) => DoctorUpdateReport::UpToDate { latest },
        Ok(std::cmp::Ordering::Greater) => DoctorUpdateReport::CurrentNewer { latest },
        Err(_) => DoctorUpdateReport::ReleaseMetadataInvalid,
    }
}

/// Canonicalize a release tag before any doctor renderer sees it. A release
/// server response is untrusted input: it may not become an error echo or a
/// terminal control sequence merely because the user opted into an update
/// check.
fn doctor_safe_release_tag(raw: &str) -> Option<String> {
    let version = raw.trim().strip_prefix('v').unwrap_or(raw.trim());
    semver::Version::parse(version)
        .ok()
        .map(|version| format!("v{version}"))
}

/// `update_command` is the install-method-aware upgrade command
/// ([`codewhale_release::InstallMethod::update_command`]): an npm, Homebrew,
/// cargo or Omarchy install must be upgraded by its package manager, never by
/// `codewhale update`, which refuses to replace a managed binary.
fn doctor_update_report_lines(report: &DoctorUpdateReport, update_command: &str) -> Vec<String> {
    match report {
        DoctorUpdateReport::NotChecked => vec![
            "latest: unknown (not checked; offline default)".to_string(),
            "Run `codewhale doctor --check-updates` to opt in.".to_string(),
        ],
        DoctorUpdateReport::UpdateAvailable { latest } => vec![
            format!("latest: {latest}"),
            format!("Update available. Run `{update_command}` to install."),
        ],
        DoctorUpdateReport::UpToDate { latest } => {
            vec![
                format!("latest: {latest}"),
                "Already up to date.".to_string(),
            ]
        }
        DoctorUpdateReport::CurrentNewer { latest } => vec![
            format!("latest: {latest}"),
            "Current build is newer than the latest published release.".to_string(),
        ],
        DoctorUpdateReport::ReleaseMetadataInvalid => vec![
            "latest: unknown (release metadata invalid; details omitted)".to_string(),
            "Run `codewhale update --check` to retry.".to_string(),
        ],
        DoctorUpdateReport::ReleaseCheckFailed => vec![
            "latest: unknown (release check failed; details omitted)".to_string(),
            "Run `codewhale update --check` to retry.".to_string(),
        ],
    }
}

/// Print the update portion of the human doctor report.
///
/// The release service is contacted only when `--check-updates` populated the
/// explicit request bit. The default branch returns before constructing an
/// HTTP request. Failure details are deliberately typed and generic because
/// transport errors and release metadata are untrusted strings.
pub(crate) async fn print_update_report(
    probes: DoctorProbeRequest,
    update: &codewhale_runtime::config::UpdateConfig,
) {
    let current_version = env!("CARGO_PKG_VERSION");
    println!("  · current: v{current_version}");
    for line in update_mode_lines(update) {
        println!("  · {line}");
    }
    let report = if probes.should_check_updates() {
        doctor_update_report(
            current_version,
            codewhale_release::latest_release_tag_async(codewhale_release::ReleaseChannel::Stable)
                .await,
        )
    } else {
        DoctorUpdateReport::NotChecked
    };
    let method = codewhale_release::current_install_method();
    for (index, line) in doctor_update_report_lines(&report, method.update_command())
        .into_iter()
        .enumerate()
    {
        let indent = if index == 0 { "  ·" } else { "   " };
        println!("{indent} {line}");
    }
}

pub(crate) fn is_keyless_ds4_route(config: &codewhale_runtime::config::Config) -> bool {
    config
        .provider
        .as_deref()
        .is_some_and(|provider| provider.eq_ignore_ascii_case("ds4"))
        && codewhale_runtime::config::base_url_uses_local_host(&config.active_route_base_url())
        && codewhale_config::auth_mode_disables_api_key(
            config
                .active_provider_identity()
                .ok()
                .and_then(|identity| config.auth_mode_for_provider(&identity))
                .as_deref(),
        )
}

/// Probe DS4 through its cheap `/v1/models` contract instead of waking the
/// model for a completion. The selected model must be advertised.
pub(crate) async fn probe_ds4_models(
    config: &codewhale_runtime::config::Config,
) -> anyhow::Result<()> {
    use codewhale_engine::client::CodewhaleClient;
    use codewhale_engine::core::model_client::ModelClient;

    let endpoint =
        codewhale_secrets::sanitize::redact_url_for_display(&config.active_route_base_url());
    let client = CodewhaleClient::new(config)?;
    let configured_alias = client.model().to_string();
    let models = match tokio::time::timeout(
        std::time::Duration::from_secs(15),
        client.list_models(),
    )
    .await
    {
        Ok(Ok(models)) => models,
        Ok(Err(error)) => anyhow::bail!(ds4_probe_error(config, &error.to_string())),
        Err(_) => anyhow::bail!("DS4 /v1/models timed out after 15 seconds at {}", endpoint),
    };
    if !models
        .iter()
        .any(|available| available.id == configured_alias)
    {
        let advertised = models
            .iter()
            .take(8)
            .map(|available| available.id.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        anyhow::bail!(
            "DS4 /v1/models at {} did not list configured alias '{configured_alias}' (advertised: {})",
            endpoint,
            if advertised.is_empty() {
                "none"
            } else {
                advertised.as_str()
            }
        );
    }
    Ok(())
}

fn ds4_probe_error(config: &codewhale_runtime::config::Config, error: &str) -> String {
    let endpoint =
        codewhale_secrets::sanitize::redact_url_for_display(&config.active_route_base_url());
    let status = error
        .split_whitespace()
        .collect::<Vec<_>>()
        .windows(2)
        .find_map(|pair| {
            (pair[0].eq_ignore_ascii_case("HTTP")
                && pair[1].trim_matches(|ch: char| !ch.is_ascii_digit()).len() == 3)
                .then(|| {
                    pair[1]
                        .trim_matches(|ch: char| !ch.is_ascii_digit())
                        .to_string()
                })
        });
    match status {
        Some(status) => format!("DS4 /v1/models returned HTTP {status} at {}", endpoint),
        None => format!(
            "DS4 /v1/models could not be reached at {} (is ds4-server running?)",
            endpoint
        ),
    }
}

#[cfg(test)]
#[path = "doctor/tests.rs"]
mod tests;

pub(crate) fn update_mode_lines(update: &codewhale_runtime::config::UpdateConfig) -> Vec<String> {
    use codewhale_release::autoupdate;
    let method = codewhale_release::current_install_method();
    let resolved = autoupdate::resolve_mode(update.mode.as_deref(), update.check_for_updates);
    let mut lines = vec![if method.supports_self_update() {
        format!("automatic updates: {}", resolved.describe())
    } else {
        format!(
            "automatic updates: not for this copy; {} updates it ({})",
            method.label(),
            method.update_command()
        )
    }];
    if let Ok(home) = codewhale_config::codewhale_home() {
        if let Some(staged) = autoupdate::load_staged(&home) {
            lines.push(format!(
                "staged: v{} ({}){}",
                staged.version,
                if staged.signed {
                    "signed; applies at next start"
                } else {
                    "checksum verified; waits for your OK"
                },
                if staged.declined {
                    "; you declined it, run `codewhale update` to install"
                } else {
                    ""
                }
            ));
        }
        if let Some(previous) = autoupdate::load_previous(&home) {
            lines.push(format!(
                "rollback: v{} is kept (`codewhale update --rollback`)",
                previous.version
            ));
        }
        if let Some(skip) = autoupdate::load_state(&home).skip_version {
            lines.push(format!(
                "skipped: v{skip} was rolled back and is not reinstalled automatically"
            ));
        }
    }
    lines
}

pub(crate) fn installation_lines(brief: bool) -> Vec<String> {
    let running = std::env::current_exe().ok();
    let inventory = codewhale_release::installs::inventory(running.as_deref());
    let mut lines = inventory.summary_lines(brief, env!("CARGO_PKG_VERSION"));
    if let Ok(home) = codewhale_config::codewhale_home() {
        let now = codewhale_release::check::now_unix();
        for folder in codewhale_release::installs::recovery_folders(&home) {
            if folder.records.is_empty() {
                continue;
            }
            lines.push(format!(
                "Recovery {}: {} item(s), {}, deleted in {} day(s). Undo: codewhale doctor --restore {}",
                folder.stamp,
                folder.records.len(),
                codewhale_release::installs::human_bytes(folder.bytes()),
                folder.expires_in_days(now).unwrap_or(0),
                folder.stamp
            ));
        }
    }
    lines
}

pub(crate) fn run_installs_report(json: bool, brief: bool) -> Result<()> {
    if !json {
        for line in installation_lines(brief) {
            println!("{line}");
        }
        return Ok(());
    }
    let running = std::env::current_exe().ok();
    let inventory = codewhale_release::installs::inventory(running.as_deref());
    let rows: Vec<_> = inventory
        .entries
        .iter()
        .map(|entry| {
            serde_json::json!({
                "path": entry.path,
                "class": entry.class.label(),
                "reason": match &entry.class {
                    codewhale_release::installs::InstallClass::NotOurs(reason) => Some(*reason),
                    _ => None,
                },
                "owner": match &entry.class {
                    codewhale_release::installs::InstallClass::PackageManaged(method) => Some(method.label()),
                    _ => None,
                },
                "owner_command": entry.owner_command(),
                "is_dir": entry.is_dir,
                "bytes": entry.bytes,
                "active": entry.active,
                "running": Some(entry.path.as_path()) == running.as_deref(),
                "same_as_running": entry.same_as_running,
            })
        })
        .collect();
    let recovery: Vec<_> = codewhale_config::codewhale_home()
        .map(|home| codewhale_release::installs::recovery_folders(&home))
        .unwrap_or_default()
        .into_iter()
        .map(|folder| {
            serde_json::json!({
                "stamp": folder.stamp,
                "path": folder.dir,
                "items": folder.records.len(),
                "bytes": folder.bytes(),
                "expires_in_days": folder.expires_in_days(codewhale_release::check::now_unix()),
            })
        })
        .collect();
    println!(
        "{}",
        serde_json::to_string_pretty(&serde_json::json!({
            "version": env!("CARGO_PKG_VERSION"),
            "installations": rows,
            "leftover_bytes": inventory.leftover_bytes(),
            "summary": inventory.summary_lines(brief, env!("CARGO_PKG_VERSION")),
            "recovery": recovery,
        }))?
    );
    Ok(())
}

pub(crate) fn run_restore(stamp: &str) -> Result<()> {
    let home = codewhale_config::codewhale_home()?;
    let stamp = (stamp != "latest").then_some(stamp);
    let (stamp, report) = codewhale_release::installs::restore(&home, stamp)?;
    for path in &report.restored {
        println!("Restored {}", path.display());
    }
    for (path, reason) in &report.skipped {
        println!("Kept in recovery: {} ({reason})", path.display());
    }
    if let Some(displaced) = &report.displaced {
        println!(
            "The files that were in those places are kept in recovery {displaced}. Undo: codewhale doctor --restore {displaced}"
        );
    }
    println!(
        "Restored {} of {} item(s) from recovery {stamp}.",
        report.restored.len(),
        report.restored.len() + report.skipped.len()
    );
    if !report.skipped.is_empty() {
        anyhow::bail!("some items could not be restored; they stay in the recovery folder");
    }
    Ok(())
}