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").ok().or(file.session_log_path),
113        context_path: std::env::var("SBH_CONTEXT_PATH").ok().or(file.context_path),
114    }
115}
116
117/// Validate a Config and return a list of human-readable error messages.
118///
119/// Should be called before dispatching any command that reaches the backend
120/// (analyze, serve, forge).  The `doctor` command bypasses this and does its
121/// own reporting so users can inspect a broken config.
122pub fn validate_config(config: &Config) -> Result<(), Vec<String>> {
123    let mut errors: Vec<String> = Vec::new();
124
125    if config.model_name.trim().is_empty() {
126        errors.push("model_name is empty — set SBH_MODEL or model_name in config.toml".into());
127    }
128
129    if config.timeout_secs == 0 {
130        errors.push(
131            "timeout_secs must be > 0 — set SBH_TIMEOUT_SECONDS or timeout_secs in config.toml"
132                .into(),
133        );
134    }
135
136    if config.serve_rate_limit == 0 {
137        errors.push(
138            "serve_rate_limit must be > 0 — set SBH_SERVE_RATE or serve_rate_limit in config.toml"
139                .into(),
140        );
141    }
142
143    if config.serve_max_body_bytes == 0 {
144        errors.push(
145            "serve_max_body_bytes must be > 0 — set SBH_SERVE_MAX_BODY or serve_max_body_bytes in config.toml"
146                .into(),
147        );
148    }
149
150    if matches!(config.backend, BackendType::Anthropic)
151        && config
152            .api_key
153            .as_deref()
154            .map(|k| k.trim().is_empty())
155            .unwrap_or(true)
156    {
157        errors.push(
158            "SBH_API_KEY is required when using the anthropic backend — \
159             set SBH_API_KEY or api_key in config.toml"
160                .into(),
161        );
162    }
163
164    if matches!(config.backend, BackendType::LocalEmbedded) {
165        errors.push(
166            "local-embedded backend is not yet implemented — \
167             use ollama-native, openai-compat, or anthropic"
168                .into(),
169        );
170    }
171
172    if errors.is_empty() {
173        Ok(())
174    } else {
175        Err(errors)
176    }
177}
178
179// ---------------------------------------------------------------------------
180// Tests
181// ---------------------------------------------------------------------------
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::types::BackendType;
187
188    fn base_config() -> Config {
189        Config {
190            backend: BackendType::OllamaNative,
191            endpoint: "http://localhost:11434".into(),
192            model_name: "llama3.2:3b".into(),
193            soul_path: String::new(),
194            api_key: None,
195            verify_mode: VerifyMode::Deterministic,
196            timeout_secs: 120,
197            dump_prompt: false,
198            dump_raw: false,
199            memory_path: None,
200            audit_path: None,
201            serve_key: None,
202            serve_rate_limit: 60,
203            serve_max_body_bytes: 1_048_576,
204            session_log_path: None,
205            context_path: None,
206        }
207    }
208
209    #[test]
210    fn valid_ollama_config_passes() {
211        assert!(validate_config(&base_config()).is_ok());
212    }
213
214    #[test]
215    fn anthropic_without_api_key_is_invalid() {
216        let mut c = base_config();
217        c.backend = BackendType::Anthropic;
218        c.api_key = None;
219        let errs = validate_config(&c).unwrap_err();
220        assert!(errs.iter().any(|e| e.contains("SBH_API_KEY")));
221    }
222
223    #[test]
224    fn anthropic_with_empty_api_key_is_invalid() {
225        let mut c = base_config();
226        c.backend = BackendType::Anthropic;
227        c.api_key = Some("   ".into());
228        let errs = validate_config(&c).unwrap_err();
229        assert!(errs.iter().any(|e| e.contains("SBH_API_KEY")));
230    }
231
232    #[test]
233    fn anthropic_with_api_key_passes() {
234        let mut c = base_config();
235        c.backend = BackendType::Anthropic;
236        c.api_key = Some("sk-ant-test".into());
237        assert!(validate_config(&c).is_ok());
238    }
239
240    #[test]
241    fn local_embedded_is_invalid() {
242        let mut c = base_config();
243        c.backend = BackendType::LocalEmbedded;
244        let errs = validate_config(&c).unwrap_err();
245        assert!(errs.iter().any(|e| e.contains("local-embedded")));
246    }
247
248    #[test]
249    fn empty_model_name_is_invalid() {
250        let mut c = base_config();
251        c.model_name = "   ".into();
252        let errs = validate_config(&c).unwrap_err();
253        assert!(errs.iter().any(|e| e.contains("model_name")));
254    }
255
256    #[test]
257    fn zero_timeout_is_invalid() {
258        let mut c = base_config();
259        c.timeout_secs = 0;
260        let errs = validate_config(&c).unwrap_err();
261        assert!(errs.iter().any(|e| e.contains("timeout_secs")));
262    }
263
264    #[test]
265    fn zero_rate_limit_is_invalid() {
266        let mut c = base_config();
267        c.serve_rate_limit = 0;
268        let errs = validate_config(&c).unwrap_err();
269        assert!(errs.iter().any(|e| e.contains("serve_rate_limit")));
270    }
271
272    #[test]
273    fn zero_max_body_is_invalid() {
274        let mut c = base_config();
275        c.serve_max_body_bytes = 0;
276        let errs = validate_config(&c).unwrap_err();
277        assert!(errs.iter().any(|e| e.contains("serve_max_body_bytes")));
278    }
279
280    #[test]
281    fn multiple_errors_all_reported() {
282        let mut c = base_config();
283        c.model_name = String::new();
284        c.timeout_secs = 0;
285        c.serve_rate_limit = 0;
286        let errs = validate_config(&c).unwrap_err();
287        assert!(errs.len() >= 3);
288    }
289
290    #[test]
291    fn parse_backend_known_values() {
292        assert!(matches!(parse_backend("ollama-native").0, BackendType::OllamaNative));
293        assert!(matches!(parse_backend("openai-compat").0, BackendType::OpenAiCompat));
294        assert!(matches!(parse_backend("anthropic").0, BackendType::Anthropic));
295        assert!(matches!(parse_backend("local-embedded").0, BackendType::LocalEmbedded));
296    }
297
298    #[test]
299    fn parse_backend_unknown_falls_back_to_ollama() {
300        // Falls back to ollama-native with a warning (warning goes to stderr, not assertable here)
301        assert!(matches!(parse_backend("typo-backend").0, BackendType::OllamaNative));
302    }
303}