saya-cli 0.3.2

Database-aware AI agent for the terminal: full-screen TUI, schema discovery, and bounded read-only SQL over PostgreSQL, MySQL, SQLite, DuckDB, and Snowflake.
//! `config doctor` — checks what a plain file listing cannot: do secrets
//! resolve, is the AI endpoint reachable, can profiles connect. Pure helpers
//! are separated so they are testable without a live environment.

use super::runtime::RuntimeConfig;
use saya_config::{AiProvider, MapSecretResolver, SecretResolver};
use saya_types::DatabaseProfile;

/// A doctor diagnosis: the report lines and whether the setup can plausibly run
/// a query. `can_run_query` is false when nothing is configured or the selected
/// profile cannot connect (an unresolved secret); warnings stay true here, so a
/// missing cloud API key or an ignored project override does not by itself make
/// doctor fail. See S18 Q4.
pub(crate) struct DoctorReport {
    pub(crate) lines: Vec<String>,
    pub(crate) can_run_query: bool,
}

impl DoctorReport {
    /// Q4: 0 when the setup can run a query, 3 (connection/config) when it cannot.
    pub(crate) fn exit_code(&self) -> i32 {
        if self.can_run_query { 0 } else { 3 }
    }
}

pub(crate) fn report(runtime: &RuntimeConfig) -> DoctorReport {
    let mut lines = vec![
        format!("config file: {}", path(&runtime.config_path)),
        format!("connections file: {}", path(&runtime.connections_path)),
        format!("profiles: {}", runtime.connections.profiles.len()),
        format!(
            "selected profile: {}",
            runtime.resolved.profile_name.as_deref().unwrap_or("none")
        ),
    ];
    lines.extend(ignored_override_lines(
        &runtime.resolved.ignored_project_overrides,
    ));
    let secret_report = secret_lines(runtime);
    let selected_unresolved = secret_report.selected_unresolved;
    lines.extend(secret_report.lines);
    lines.extend(provider_lines(
        runtime.resolved.ai.provider,
        runtime.resolved.ai.base_url.as_deref(),
        runtime.resolved.ai.api_key.is_some(),
    ));
    lines.extend(advice_lines(runtime, selected_unresolved));
    DoctorReport {
        lines,
        can_run_query: can_run_query(runtime, selected_unresolved),
    }
}

/// The TUI's `/doctor` shows the report text; it does not consume the exit code.
pub(crate) fn summary(runtime: &RuntimeConfig) -> String {
    report(runtime).lines.join("\n")
}

/// Actionable next steps. The factual lines above stay; this only adds. A first
/// run that "ends somewhere" needs doctor to say what to do, not just that
/// something is missing — see S18 deliverable 4.
fn advice_lines(runtime: &RuntimeConfig, selected_unresolved: bool) -> Vec<String> {
    let mut advice: Vec<String> = Vec::new();
    if runtime.config_path.is_none() && runtime.connections_path.is_none() {
        advice.push(
            "  nothing is configured — run `saya config init` to create starter \
             config in your user directory."
                .into(),
        );
    } else if runtime.config_path.is_none() {
        advice.push(
            "  no config.toml found — run `saya config init` to write one to your \
             user directory."
                .into(),
        );
    } else if runtime.connections_path.is_none() {
        advice.push(
            "  no connections.toml found — run `saya config init` to add a starter \
             profile."
                .into(),
        );
    }
    if runtime.resolved.profile.is_none()
        && !advice.iter().any(|line| line.contains("saya config init"))
    {
        advice.push(
            "  no profile is selected — run `saya config init`, or set \
             `default_profile` in your config."
                .into(),
        );
    }
    if selected_unresolved {
        advice.push(
            "  the selected profile's secret does not resolve — set the referenced \
             environment variable (or add it to a .env.saya file passed with \
             --env-file), then re-run."
                .into(),
        );
    }
    advice
}

fn can_run_query(runtime: &RuntimeConfig, selected_unresolved: bool) -> bool {
    runtime.resolved.profile.is_some() && !selected_unresolved
}

/// Names the security-critical settings the project layer tried to change and
/// did not get. The one-shot warning is deliberately short, so this is where a
/// user finds out *which* settings were ignored and what to do about it —
/// doctor is the "what is wrong with my setup" command, and this is its
/// subject. Empty when the project layer changed none of them.
fn ignored_override_lines(ignored: &[String]) -> Vec<String> {
    if ignored.is_empty() {
        return Vec::new();
    }
    vec![
        format!(
            "ignored from project config: {} (the project layer is untrusted)",
            ignored.join(", ")
        ),
        "  these decide where your API key is sent, whether rows leave the machine,".into(),
        "  and whether read-only enforcement stays on — so a cloned repository does".into(),
        "  not get to set them. Move them to your user config to have them applied,".into(),
        "  or pass --trust-project-config to accept this project's values.".into(),
    ]
}

fn path(value: &Option<std::path::PathBuf>) -> String {
    value
        .as_deref()
        .map(|path| path.display().to_string())
        .unwrap_or_else(|| "not found".into())
}

/// Dry-runs every secret reference in every profile through the resolver.
/// Values are never printed — only whether each reference resolves.
struct SecretReport {
    lines: Vec<String>,
    /// True when the *selected* profile has a referenced secret that does not
    /// resolve — the one case that means a query cannot connect.
    selected_unresolved: bool,
}

fn secret_lines(runtime: &RuntimeConfig) -> SecretReport {
    let resolver = MapSecretResolver::new(runtime.secret_values.clone());
    let selected = runtime.resolved.profile_name.as_deref();
    let mut lines = Vec::new();
    let mut selected_unresolved = false;
    for (name, profile) in &runtime.connections.profiles {
        let references = profile_secrets(profile);
        if references.is_empty() {
            continue;
        }
        for label in references {
            match resolver.resolve(label) {
                Ok(_) => lines.push(format!("{name}: {} resolves", label.redacted_label())),
                Err(error) => {
                    if Some(name.as_str()) == selected {
                        selected_unresolved = true;
                    }
                    lines.push(format!(
                        "{name}: {} does not resolve ({error})",
                        label.redacted_label()
                    ));
                }
            }
        }
    }
    if lines.is_empty() {
        lines.push("secrets: none referenced".to_string());
    }
    SecretReport {
        lines,
        selected_unresolved,
    }
}

fn profile_secrets(profile: &DatabaseProfile) -> Vec<&saya_types::SecretRef> {
    match profile {
        DatabaseProfile::Postgres { password, .. } => password.iter().collect(),
        DatabaseProfile::Mysql {
            password, ssl_ca, ..
        } => password.iter().chain(ssl_ca.iter()).collect(),
        DatabaseProfile::DuckDb { .. } | DatabaseProfile::Sqlite { .. } => Vec::new(),
        DatabaseProfile::Snowflake {
            private_key,
            password,
            passphrase,
            ..
        } => private_key
            .iter()
            .chain(password.iter())
            .chain(passphrase.iter())
            .collect(),
    }
}

/// Host/port the AI provider would actually be contacted on, so doctor can
/// probe it. `None` means there is nothing meaningful to probe (a gateway
/// with no default address).
fn provider_endpoint(provider: AiProvider, base_url: Option<&str>) -> Option<(String, u16)> {
    const DEFAULTS: [(AiProvider, &str, u16); 4] = [
        (AiProvider::Ollama, "localhost", 11_434),
        (AiProvider::Openai, "api.openai.com", 443),
        (AiProvider::Anthropic, "api.anthropic.com", 443),
        (AiProvider::Gemini, "generativelanguage.googleapis.com", 443),
    ];
    if let Some(url) = base_url {
        return parse_host_port(url);
    }
    DEFAULTS
        .iter()
        .find(|(candidate, _, _)| *candidate == provider)
        .map(|(_, host, port)| ((*host).to_string(), *port))
}

/// Minimal `scheme://host[:port]` extraction without pulling in a URL crate.
/// A string without a scheme is not treated as a host.
fn parse_host_port(url: &str) -> Option<(String, u16)> {
    let scheme_end = url.find("://")?;
    let is_tls = url[..scheme_end].eq_ignore_ascii_case("https");
    let after_scheme = &url[scheme_end + 3..];
    let authority = after_scheme.split(['/', '?', '#']).next()?;
    let authority = authority.rsplit('@').next()?;
    if let Some((host, port)) = authority.rsplit_once(':') {
        let trimmed = host.trim_matches(|character| character == '[' || character == ']');
        return Some((trimmed.to_string(), port.parse().ok()?));
    }
    Some((authority.to_string(), if is_tls { 443 } else { 80 }))
}

/// What doctor says about the AI side before any network I/O.
fn provider_lines(provider: AiProvider, base_url: Option<&str>, has_key_ref: bool) -> Vec<String> {
    let mut lines = vec![format!(
        "ai provider: {} model: (from config)",
        provider.as_str()
    )];
    if matches!(
        provider,
        AiProvider::Openai | AiProvider::Anthropic | AiProvider::Gemini
    ) && !has_key_ref
    {
        lines.push(format!(
            "! no api_key reference configured — requests to {} will be unauthenticated",
            provider.as_str()
        ));
    }
    match provider_endpoint(provider, base_url) {
        Some((host, port)) => lines.push(format!("probe: {host}:{port}")),
        None => lines.push("probe: no endpoint to check".to_string()),
    }
    lines
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn host_and_port_extraction_handles_common_shapes() {
        assert_eq!(
            parse_host_port("http://localhost:11434"),
            Some(("localhost".into(), 11_434))
        );
        assert_eq!(
            parse_host_port("https://api.anthropic.com/v1"),
            Some(("api.anthropic.com".into(), 443))
        );
        assert_eq!(
            parse_host_port("http://10.0.0.4:8080/v1"),
            Some(("10.0.0.4".into(), 8080))
        );
        assert_eq!(parse_host_port("not a url"), None);
    }

    #[test]
    fn defaults_are_probed_when_base_url_is_absent() {
        assert_eq!(
            provider_endpoint(AiProvider::Ollama, None),
            Some(("localhost".into(), 11_434))
        );
        // A configured base_url wins over the default.
        assert_eq!(
            provider_endpoint(AiProvider::Ollama, Some("http://box.lan:9999")),
            Some(("box.lan".into(), 9_999))
        );
    }

    #[test]
    fn cloud_provider_without_key_reference_warns() {
        let lines = provider_lines(AiProvider::Anthropic, None, false);
        assert!(lines.iter().any(|line| line.contains("unauthenticated")));
        let lines = provider_lines(AiProvider::Anthropic, None, true);
        assert!(!lines.iter().any(|line| line.contains("unauthenticated")));
    }
}

#[cfg(test)]
mod ignored_override_tests {
    use super::ignored_override_lines;

    /// The one-shot warning is deliberately terse, so doctor is the only place
    /// a user can learn *which* settings were ignored. If this stops reporting
    /// them, the terse warning becomes a dead end.
    #[test]
    fn doctor_names_every_ignored_setting_and_the_way_to_apply_it() {
        let report =
            ignored_override_lines(&["ai.base_url".to_string(), "run.read_only".to_string()])
                .join("\n");

        assert!(
            report.contains("ai.base_url") && report.contains("run.read_only"),
            "doctor must name each ignored setting: {report}"
        );
        // Both routes: the trusted one (move them) and the override.
        assert!(
            report.contains("user config") && report.contains("--trust-project-config"),
            "doctor must say how to apply them: {report}"
        );
    }

    /// A project layer that changed nothing adds no noise to the report.
    #[test]
    fn doctor_is_silent_when_nothing_was_ignored() {
        assert!(
            ignored_override_lines(&[]).is_empty(),
            "no ignored settings means no section"
        );
    }
}