split_brain_harness/
config.rs1use 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
30pub 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
60pub 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
117pub 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#[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 assert!(matches!(parse_backend("typo-backend").0, BackendType::OllamaNative));
302 }
303}