Skip to main content

split_brain_harness/
config.rs

1use crate::types::{BackendType, Config, VerifyMode};
2use serde::Deserialize;
3
4#[derive(Deserialize, Default)]
5struct FileConfig {
6    backend: Option<String>,
7    endpoint: Option<String>,
8    model_name: Option<String>,
9    soul_path: Option<String>,
10    api_key: Option<String>,
11    verify_mode: Option<String>,
12    timeout_secs: Option<u64>,
13    memory_path: Option<String>,
14    audit_path: Option<String>,
15    serve_key: Option<String>,
16    serve_rate_limit: Option<u32>,
17    serve_max_body_bytes: Option<usize>,
18    session_log_path: Option<String>,
19    context_path: Option<String>,
20}
21
22fn load_file_config() -> FileConfig {
23    let path = std::env::var("SBH_CONFIG").unwrap_or_else(|_| "config.toml".to_string());
24    match std::fs::read_to_string(&path) {
25        Ok(c) => toml::from_str(&c).unwrap_or_default(),
26        Err(_) => FileConfig::default(),
27    }
28}
29
30/// Maps a backend name string to a BackendType and its default endpoint.
31///
32/// Unrecognized strings produce a warning on stderr and fall back to
33/// `ollama-native`.  Valid values: `ollama-native`, `openai-compat`,
34/// `anthropic`, `local-embedded`.
35pub fn parse_backend(s: &str) -> (BackendType, &'static str) {
36    match s {
37        "openai-compat" => (BackendType::OpenAiCompat, "http://localhost:8080"),
38        "anthropic" => (BackendType::Anthropic, "https://api.anthropic.com"),
39        "local-embedded" => (BackendType::LocalEmbedded, ""),
40        "ollama-native" => (BackendType::OllamaNative, "http://localhost:11434"),
41        other => {
42            eprintln!(
43                "warning: unrecognized SBH_BACKEND={other:?} — \
44                 valid values: ollama-native, openai-compat, anthropic, local-embedded. \
45                 Falling back to ollama-native."
46            );
47            (BackendType::OllamaNative, "http://localhost:11434")
48        }
49    }
50}
51
52pub fn parse_verify_mode(s: &str) -> VerifyMode {
53    match s {
54        "llm" => VerifyMode::Llm,
55        "none" => VerifyMode::None,
56        _ => VerifyMode::Deterministic,
57    }
58}
59
60/// Build Config from env vars → config.toml → hardcoded defaults.
61pub fn build_config() -> Config {
62    let file = load_file_config();
63    let backend_str = std::env::var("SBH_BACKEND")
64        .ok()
65        .or(file.backend)
66        .unwrap_or_else(|| "ollama-native".to_string());
67    let (backend, default_ep) = parse_backend(&backend_str);
68    let default_model = match &backend {
69        BackendType::Anthropic => "claude-sonnet-4-6",
70        _ => "llama3.2:3b",
71    };
72    Config {
73        backend,
74        endpoint: std::env::var("SBH_ENDPOINT")
75            .ok()
76            .or(file.endpoint)
77            .unwrap_or_else(|| default_ep.to_string()),
78        model_name: std::env::var("SBH_MODEL")
79            .ok()
80            .or(file.model_name)
81            .unwrap_or_else(|| default_model.to_string()),
82        soul_path: std::env::var("SBH_SOUL_PATH")
83            .ok()
84            .or(file.soul_path)
85            .unwrap_or_default(),
86        api_key: std::env::var("SBH_API_KEY").ok().or(file.api_key),
87        verify_mode: std::env::var("SBH_VERIFY")
88            .ok()
89            .or(file.verify_mode)
90            .map(|s| parse_verify_mode(&s))
91            .unwrap_or_default(),
92        timeout_secs: std::env::var("SBH_TIMEOUT_SECONDS")
93            .ok()
94            .and_then(|s| s.parse().ok())
95            .or(file.timeout_secs)
96            .unwrap_or(120),
97        dump_prompt: false,
98        dump_raw: false,
99        memory_path: std::env::var("SBH_MEMORY_PATH").ok().or(file.memory_path),
100        audit_path: std::env::var("SBH_AUDIT_PATH").ok().or(file.audit_path),
101        serve_key: std::env::var("SBH_SERVE_KEY").ok().or(file.serve_key),
102        serve_rate_limit: std::env::var("SBH_SERVE_RATE")
103            .ok()
104            .and_then(|s| s.parse().ok())
105            .or(file.serve_rate_limit)
106            .unwrap_or(60),
107        serve_max_body_bytes: std::env::var("SBH_SERVE_MAX_BODY")
108            .ok()
109            .and_then(|s| s.parse().ok())
110            .or(file.serve_max_body_bytes)
111            .unwrap_or(1_048_576),
112        session_log_path: std::env::var("SBH_SESSION_LOG")
113            .ok()
114            .or(file.session_log_path),
115        context_path: std::env::var("SBH_CONTEXT_PATH").ok().or(file.context_path),
116    }
117}
118
119/// Validate a Config and return a list of human-readable error messages.
120///
121/// Should be called before dispatching any command that reaches the backend
122/// (analyze, serve, forge).  The `doctor` command bypasses this and does its
123/// own reporting so users can inspect a broken config.
124pub fn validate_config(config: &Config) -> Result<(), Vec<String>> {
125    let mut errors: Vec<String> = Vec::new();
126
127    if config.model_name.trim().is_empty() {
128        errors.push("model_name is empty — set SBH_MODEL or model_name in config.toml".into());
129    }
130
131    if config.timeout_secs == 0 {
132        errors.push(
133            "timeout_secs must be > 0 — set SBH_TIMEOUT_SECONDS or timeout_secs in config.toml"
134                .into(),
135        );
136    }
137
138    if config.serve_rate_limit == 0 {
139        errors.push(
140            "serve_rate_limit must be > 0 — set SBH_SERVE_RATE or serve_rate_limit in config.toml"
141                .into(),
142        );
143    }
144
145    if config.serve_max_body_bytes == 0 {
146        errors.push(
147            "serve_max_body_bytes must be > 0 — set SBH_SERVE_MAX_BODY or serve_max_body_bytes in config.toml"
148                .into(),
149        );
150    }
151
152    if matches!(config.backend, BackendType::Anthropic)
153        && config
154            .api_key
155            .as_deref()
156            .map(|k| k.trim().is_empty())
157            .unwrap_or(true)
158    {
159        errors.push(
160            "SBH_API_KEY is required when using the anthropic backend — \
161             set SBH_API_KEY or api_key in config.toml"
162                .into(),
163        );
164    }
165
166    if matches!(config.backend, BackendType::LocalEmbedded) {
167        errors.push(
168            "local-embedded backend is not yet implemented — \
169             use ollama-native, openai-compat, or anthropic"
170                .into(),
171        );
172    }
173
174    if errors.is_empty() {
175        Ok(())
176    } else {
177        Err(errors)
178    }
179}
180
181// ---------------------------------------------------------------------------
182// Tests
183// ---------------------------------------------------------------------------
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::types::BackendType;
189
190    fn base_config() -> Config {
191        Config {
192            backend: BackendType::OllamaNative,
193            endpoint: "http://localhost:11434".into(),
194            model_name: "llama3.2:3b".into(),
195            soul_path: String::new(),
196            api_key: None,
197            verify_mode: VerifyMode::Deterministic,
198            timeout_secs: 120,
199            dump_prompt: false,
200            dump_raw: false,
201            memory_path: None,
202            audit_path: None,
203            serve_key: None,
204            serve_rate_limit: 60,
205            serve_max_body_bytes: 1_048_576,
206            session_log_path: None,
207            context_path: None,
208        }
209    }
210
211    #[test]
212    fn valid_ollama_config_passes() {
213        assert!(validate_config(&base_config()).is_ok());
214    }
215
216    #[test]
217    fn anthropic_without_api_key_is_invalid() {
218        let mut c = base_config();
219        c.backend = BackendType::Anthropic;
220        c.api_key = None;
221        let errs = validate_config(&c).unwrap_err();
222        assert!(errs.iter().any(|e| e.contains("SBH_API_KEY")));
223    }
224
225    #[test]
226    fn anthropic_with_empty_api_key_is_invalid() {
227        let mut c = base_config();
228        c.backend = BackendType::Anthropic;
229        c.api_key = Some("   ".into());
230        let errs = validate_config(&c).unwrap_err();
231        assert!(errs.iter().any(|e| e.contains("SBH_API_KEY")));
232    }
233
234    #[test]
235    fn anthropic_with_api_key_passes() {
236        let mut c = base_config();
237        c.backend = BackendType::Anthropic;
238        c.api_key = Some("sk-ant-test".into());
239        assert!(validate_config(&c).is_ok());
240    }
241
242    #[test]
243    fn local_embedded_is_invalid() {
244        let mut c = base_config();
245        c.backend = BackendType::LocalEmbedded;
246        let errs = validate_config(&c).unwrap_err();
247        assert!(errs.iter().any(|e| e.contains("local-embedded")));
248    }
249
250    #[test]
251    fn empty_model_name_is_invalid() {
252        let mut c = base_config();
253        c.model_name = "   ".into();
254        let errs = validate_config(&c).unwrap_err();
255        assert!(errs.iter().any(|e| e.contains("model_name")));
256    }
257
258    #[test]
259    fn zero_timeout_is_invalid() {
260        let mut c = base_config();
261        c.timeout_secs = 0;
262        let errs = validate_config(&c).unwrap_err();
263        assert!(errs.iter().any(|e| e.contains("timeout_secs")));
264    }
265
266    #[test]
267    fn zero_rate_limit_is_invalid() {
268        let mut c = base_config();
269        c.serve_rate_limit = 0;
270        let errs = validate_config(&c).unwrap_err();
271        assert!(errs.iter().any(|e| e.contains("serve_rate_limit")));
272    }
273
274    #[test]
275    fn zero_max_body_is_invalid() {
276        let mut c = base_config();
277        c.serve_max_body_bytes = 0;
278        let errs = validate_config(&c).unwrap_err();
279        assert!(errs.iter().any(|e| e.contains("serve_max_body_bytes")));
280    }
281
282    #[test]
283    fn multiple_errors_all_reported() {
284        let mut c = base_config();
285        c.model_name = String::new();
286        c.timeout_secs = 0;
287        c.serve_rate_limit = 0;
288        let errs = validate_config(&c).unwrap_err();
289        assert!(errs.len() >= 3);
290    }
291
292    #[test]
293    fn parse_backend_known_values() {
294        assert!(matches!(
295            parse_backend("ollama-native").0,
296            BackendType::OllamaNative
297        ));
298        assert!(matches!(
299            parse_backend("openai-compat").0,
300            BackendType::OpenAiCompat
301        ));
302        assert!(matches!(
303            parse_backend("anthropic").0,
304            BackendType::Anthropic
305        ));
306        assert!(matches!(
307            parse_backend("local-embedded").0,
308            BackendType::LocalEmbedded
309        ));
310    }
311
312    #[test]
313    fn parse_backend_unknown_falls_back_to_ollama() {
314        // Falls back to ollama-native with a warning (warning goes to stderr, not assertable here)
315        assert!(matches!(
316            parse_backend("typo-backend").0,
317            BackendType::OllamaNative
318        ));
319    }
320}