Skip to main content

sparrow/config/
validate.rs

1use crate::config::Config;
2
3/// Validate config and return human-readable warnings/errors
4pub fn validate_config(config: &Config) -> Vec<ConfigIssue> {
5    let mut issues = Vec::new();
6
7    // Budget sanity
8    if config.budget.daily_usd <= 0.0 {
9        issues.push(ConfigIssue::warn(
10            "budget.daily_usd is 0 — no cloud providers will be used",
11        ));
12    }
13    if config.budget.session_usd <= 0.0 {
14        issues.push(ConfigIssue::warn(
15            "budget.session_usd is 0 — runs will stop immediately",
16        ));
17    }
18    if config.budget.daily_usd > 100.0 {
19        issues.push(ConfigIssue::warn(
20            "budget.daily_usd is very high ($100+). Consider setting a reasonable cap",
21        ));
22    }
23
24    // Provider check
25    if config.providers.is_empty() {
26        issues.push(ConfigIssue::info("No providers configured. Add one with 'sparrow auth add' or set *_API_KEY env vars. Ollama will be tried as fallback."));
27    }
28    for (name, pconfig) in &config.providers {
29        if pconfig.models.is_empty() {
30            issues.push(ConfigIssue::warn(&format!(
31                "Provider '{}' has no models configured",
32                name
33            )));
34        }
35        if pconfig.adapter.is_empty() {
36            issues.push(ConfigIssue::error(&format!(
37                "Provider '{}' has no adapter set",
38                name
39            )));
40        }
41    }
42
43    // Autonomy sanity
44    if config.routing.free_first && config.providers.is_empty() {
45        issues.push(ConfigIssue::info(
46            "routing.free_first is enabled but no free providers configured. Ollama will be tried.",
47        ));
48    }
49
50    // Sandbox
51    if config.defaults.sandbox == "local-hardened" && !cfg!(target_os = "linux") {
52        issues.push(ConfigIssue::warn(
53            "sandbox 'local-hardened' requires Linux. Falling back to 'local'.",
54        ));
55    }
56
57    // Skills dir
58    if !config.skills.dir.exists() {
59        issues.push(ConfigIssue::info(&format!(
60            "Skills directory does not exist: {}. It will be created automatically.",
61            config.skills.dir.display()
62        )));
63    }
64
65    issues
66}
67
68#[derive(Debug, Clone)]
69pub enum IssueLevel {
70    Info,
71    Warning,
72    Error,
73}
74
75#[derive(Debug, Clone)]
76pub struct ConfigIssue {
77    pub level: IssueLevel,
78    pub message: String,
79}
80
81impl ConfigIssue {
82    pub fn info(msg: &str) -> Self {
83        Self {
84            level: IssueLevel::Info,
85            message: msg.into(),
86        }
87    }
88    pub fn warn(msg: &str) -> Self {
89        Self {
90            level: IssueLevel::Warning,
91            message: msg.into(),
92        }
93    }
94    pub fn error(msg: &str) -> Self {
95        Self {
96            level: IssueLevel::Error,
97            message: msg.into(),
98        }
99    }
100
101    pub fn icon(&self) -> &str {
102        match self.level {
103            IssueLevel::Info => "ℹ",
104            IssueLevel::Warning => "⚠",
105            IssueLevel::Error => "✗",
106        }
107    }
108}
109
110/// Ping a provider to check if it's reachable and the API key works
111pub async fn ping_provider(
112    name: &str,
113    base_url: &str,
114    api_key: &str,
115    adapter: &str,
116) -> ProviderHealth {
117    let url = match adapter {
118        "ollama" => format!("{}/api/tags", base_url.trim_end_matches('/')),
119        "openai-compatible" => format!("{}/models", base_url.trim_end_matches('/')),
120        "anthropic-messages" => format!("{}/v1/messages", base_url.trim_end_matches('/')),
121        _ => format!("{}/models", base_url.trim_end_matches('/')),
122    };
123
124    let client = reqwest::Client::builder()
125        .timeout(std::time::Duration::from_secs(5))
126        .build()
127        .unwrap_or_default();
128
129    let mut req = client.get(&url);
130    if adapter != "ollama" && !api_key.is_empty() {
131        req = req.header("Authorization", format!("Bearer {}", api_key));
132    }
133
134    match req.send().await {
135        Ok(resp) => {
136            let status = resp.status().as_u16();
137            ProviderHealth {
138                name: name.into(),
139                reachable: status < 500,
140                status_code: status,
141                latency_ms: 0,
142                message: if status == 200 {
143                    "OK".into()
144                } else {
145                    format!("HTTP {}", status)
146                },
147            }
148        }
149        Err(e) => ProviderHealth {
150            name: name.into(),
151            reachable: false,
152            status_code: 0,
153            latency_ms: 0,
154            message: format!("{}", e),
155        },
156    }
157}
158
159#[derive(Debug, Clone)]
160pub struct ProviderHealth {
161    pub name: String,
162    pub reachable: bool,
163    pub status_code: u16,
164    pub latency_ms: u64,
165    pub message: String,
166}