use super::runtime::RuntimeConfig;
use saya_config::{AiProvider, MapSecretResolver, SecretResolver};
use saya_types::DatabaseProfile;
use url::Url;
pub(crate) struct DoctorReport {
pub(crate) lines: Vec<String>,
pub(crate) can_run_query: bool,
}
impl DoctorReport {
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(),
runtime.resolved.ai.max_output_tokens,
runtime.resolved.ai.max_output_tokens_is_default,
runtime.resolved.ai.compaction,
));
lines.extend(advice_lines(runtime, selected_unresolved));
DoctorReport {
lines,
can_run_query: can_run_query(runtime, selected_unresolved),
}
}
pub(crate) fn summary(runtime: &RuntimeConfig) -> String {
report(runtime).lines.join("\n")
}
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
}
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())
}
struct SecretReport {
lines: Vec<String>,
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::ClickHouse { password, .. } => password.iter().collect(),
DatabaseProfile::BigQuery {
service_account_key,
..
} => std::iter::once(service_account_key).collect(),
DatabaseProfile::Snowflake {
private_key,
password,
passphrase,
..
} => private_key
.iter()
.chain(password.iter())
.chain(passphrase.iter())
.collect(),
}
}
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))
}
fn parse_host_port(url: &str) -> Option<(String, u16)> {
let scheme_end = url.find("://")?;
let authority_start = scheme_end + 3;
let first = url.as_bytes().get(authority_start).copied()?;
if matches!(first, b'/' | b'?' | b'#') {
return None;
}
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.trim_matches(|c| c == '[' || c == ']');
let port = parsed.port_or_known_default()?;
Some((host.to_string(), port))
}
fn provider_lines(
provider: AiProvider,
base_url: Option<&str>,
has_key_ref: bool,
max_output_tokens: u32,
max_output_tokens_is_default: bool,
compaction: saya_config::CompactionMode,
) -> Vec<String> {
let mut lines = vec![format!(
"ai provider: {} model: (from config)",
provider.as_str()
)];
if max_output_tokens_is_default {
lines.push(format!(
"ai max_output_tokens: {max_output_tokens} (default 4096 when unset)"
));
} else {
lines.push(format!("ai max_output_tokens: {max_output_tokens}"));
}
lines.push(format!("ai compaction: {}", compaction.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!("configured endpoint: {host}:{port}")),
None => lines.push("configured endpoint: none".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_reported_when_base_url_is_absent() {
assert_eq!(
provider_endpoint(AiProvider::Ollama, None),
Some(("localhost".into(), 11_434))
);
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,
4096,
true,
saya_config::CompactionMode::Auto,
);
assert!(lines.iter().any(|line| line.contains("unauthenticated")));
let lines = provider_lines(
AiProvider::Anthropic,
None,
true,
4096,
true,
saya_config::CompactionMode::Auto,
);
assert!(!lines.iter().any(|line| line.contains("unauthenticated")));
}
#[test]
fn parse_host_port_extracts_a_plain_https_host_and_port() {
assert_eq!(
parse_host_port("https://api.openai.com:5432"),
Some(("api.openai.com".into(), 5432))
);
}
#[test]
fn parse_host_port_strips_userinfo_before_host_and_port() {
assert_eq!(
parse_host_port("https://user:pass@host:5432"),
Some(("host".into(), 5432))
);
assert_eq!(
parse_host_port("https://user:pass@[::1]"),
Some(("::1".into(), 443))
);
}
#[test]
fn parse_host_port_handles_ipv6_literals() {
assert_eq!(
parse_host_port("http://[::1]:5432"),
Some(("::1".into(), 5432))
);
assert_eq!(
parse_host_port("https://[2001:db8::1]"),
Some(("2001:db8::1".into(), 443))
);
}
#[test]
fn parse_host_port_rejects_strings_without_a_scheme() {
assert_eq!(parse_host_port("api.openai.com"), None);
assert_eq!(parse_host_port("localhost:11434"), None);
assert_eq!(parse_host_port("not a url"), None);
}
#[test]
fn parse_host_port_rejects_an_empty_authority() {
assert_eq!(parse_host_port("http:///path"), None);
}
#[test]
fn provider_lines_report_an_ipv6_endpoint_without_an_explicit_port() {
let lines = provider_lines(
AiProvider::Ollama,
Some("http://[::1]"),
true,
4096,
true,
saya_config::CompactionMode::Auto,
);
assert!(
lines
.iter()
.any(|line| line.contains("configured endpoint: ::1:80")),
"doctor should report the IPv6 endpoint on the http default port: {lines:?}"
);
}
#[test]
fn closed_loopback_endpoint_is_reported_without_claiming_reachability() {
let lines = provider_lines(
AiProvider::Ollama,
Some("http://127.0.0.1:1"),
true,
4096,
true,
saya_config::CompactionMode::Auto,
);
let report = lines.join("\n");
assert!(report.contains("configured endpoint: 127.0.0.1:1"));
assert!(!report.contains("probe"));
assert!(!report.contains("reachable"));
}
#[test]
fn provider_lines_names_the_stated_output_token_ceiling_without_a_default_note() {
let lines = provider_lines(
AiProvider::Ollama,
None,
true,
2048,
false,
saya_config::CompactionMode::Auto,
);
assert!(
lines
.iter()
.any(|line| line == "ai max_output_tokens: 2048"),
"a stated ceiling is reported bare, with no default note: {lines:?}"
);
}
#[test]
fn provider_lines_notes_the_default_output_token_ceiling_when_unset() {
let lines = provider_lines(
AiProvider::Ollama,
None,
true,
4096,
true,
saya_config::CompactionMode::Auto,
);
assert!(
lines
.iter()
.any(|line| line == "ai max_output_tokens: 4096 (default 4096 when unset)"),
"an unset ceiling is reported with the default note: {lines:?}"
);
}
#[test]
fn the_output_token_ceiling_line_is_informational_only() {
for (tokens, from_default) in [(2048, false), (4096, true)] {
let lines = provider_lines(
AiProvider::Ollama,
None,
true,
tokens,
from_default,
saya_config::CompactionMode::Auto,
);
assert!(
!lines.iter().any(|line| line.starts_with('!')),
"the ceiling line must never warn: {lines:?}"
);
}
}
#[test]
fn the_ceiling_line_never_changes_whether_doctor_can_run() {
for toml in [
"[ai]\nmax_output_tokens = 2048\n",
"[ai]\nmax_output_tokens = 4096\n",
"",
] {
let report = test_report(toml);
assert!(
report.can_run_query,
"a resolvable profile must stay runnable (config {toml:?}): {:?}",
report.lines
);
assert!(
report
.lines
.iter()
.any(|line| line.contains("max_output_tokens")),
"the ceiling line is present in every case: {:?}",
report.lines
);
assert_eq!(report.exit_code(), 0);
}
}
fn test_report(ai_toml: &str) -> DoctorReport {
use crate::config::runtime::RuntimeConfig;
use saya_config::{ConfigFile, ConnectionsFile, ResolutionInput, resolve};
let raw = format!(
"default_profile = \"analytics\"\n{ai_toml}\n[profiles.analytics]\ntype = \"sqlite\"\npath = \"/tmp/x.sqlite\"\n"
);
let (config_raw, connections_raw) = raw.split_once("[profiles.").unwrap();
let config = ConfigFile::from_toml(config_raw).unwrap();
let connections =
ConnectionsFile::from_toml(&format!("[profiles.{connections_raw}")).unwrap();
let resolved =
resolve(ResolutionInput::new(connections.clone()).with_user(config)).unwrap();
report(&RuntimeConfig {
resolved,
connections,
config_path: None,
connections_path: None,
cache_scope: std::path::PathBuf::from("/tmp/saya-doctor-test"),
secret_values: Default::default(),
})
}
}
#[cfg(test)]
mod ignored_override_tests {
use super::ignored_override_lines;
#[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}"
);
assert!(
report.contains("user config") && report.contains("--trust-project-config"),
"doctor must say how to apply them: {report}"
);
}
#[test]
fn doctor_is_silent_when_nothing_was_ignored() {
assert!(
ignored_override_lines(&[]).is_empty(),
"no ignored settings means no section"
);
}
}