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")
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
119pub 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#[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 assert!(matches!(
316 parse_backend("typo-backend").0,
317 BackendType::OllamaNative
318 ));
319 }
320}