seher/sdk/
config_loader.rs1use std::path::{Path, PathBuf};
9
10use super::config::{Config, ConfigRaw};
11
12#[derive(Debug, thiserror::Error)]
13pub enum ConfigError {
14 #[error("Failed to read config file '{path}': {source}")]
15 Io {
16 path: PathBuf,
17 #[source]
18 source: std::io::Error,
19 },
20 #[error("Failed to parse YAML config: {0}")]
21 Parse(#[from] serde_yaml::Error),
22 #[error("Invalid config: {0}")]
23 Invalid(String),
24 #[error("$HOME is not set; cannot resolve default config path")]
25 HomeNotSet,
26}
27
28pub fn resolve_config_path(override_path: Option<&Path>) -> Result<Option<PathBuf>, ConfigError> {
36 if let Some(p) = override_path {
37 return Ok(Some(p.to_path_buf()));
38 }
39 if let Ok(env_path) = std::env::var("SEHER_CONFIG")
40 && !env_path.is_empty()
41 {
42 return Ok(Some(PathBuf::from(env_path)));
43 }
44 let home = dirs::home_dir().ok_or(ConfigError::HomeNotSet)?;
45 let default = home.join(".config").join("seher").join("config.yaml");
46 if default.exists() {
47 return Ok(Some(default));
48 }
49 Ok(None)
50}
51
52pub fn load_config(override_path: Option<&Path>) -> Result<Config, ConfigError> {
61 let Some(path) = resolve_config_path(override_path)? else {
62 return Ok(Config::default());
63 };
64 let bytes = std::fs::read(&path).map_err(|source| ConfigError::Io {
65 path: path.clone(),
66 source,
67 })?;
68 let raw: ConfigRaw = serde_yaml::from_slice(&bytes)?;
69 let cfg: Config = raw.into();
70 validate(&cfg)?;
71 Ok(cfg)
72}
73
74fn validate(cfg: &Config) -> Result<(), ConfigError> {
75 for entry in &cfg.providers {
80 if entry.models.is_empty() {
81 return Err(ConfigError::Invalid(format!(
82 "Provider '{}' defines no models",
83 entry.key
84 )));
85 }
86 for (mode_key, m) in &entry.models {
87 if m.model.is_empty() {
88 return Err(ConfigError::Invalid(format!(
89 "Provider '{}' model '{}' has empty model id",
90 entry.key, mode_key
91 )));
92 }
93 }
94 }
95 Ok(())
96}
97
98#[cfg(test)]
99#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
100mod tests {
101 use super::*;
102
103 type TestResult = Result<(), Box<dyn std::error::Error>>;
104
105 #[test]
106 fn missing_file_at_override_path_errors() -> TestResult {
107 let tmp = tempfile::tempdir()?;
108 let path = tmp.path().join("does-not-exist.yaml");
109 let err = load_config(Some(&path)).expect_err("should fail on missing file");
110 assert!(matches!(err, ConfigError::Io { .. }));
111 Ok(())
112 }
113
114 #[test]
115 fn parses_sample_yaml() -> TestResult {
116 let yaml = "
117providers:
118 claude:
119 priority: 3
120 models:
121 plan: opus-4.7
122 build: sonnet-4.6
123 codex:
124 models:
125 plan: { model: gpt-5.5, priority: 5 }
126 build: { model: gpt-5.5, priority: 4 }
127";
128 let tmp = tempfile::NamedTempFile::new()?;
129 std::fs::write(tmp.path(), yaml)?;
130 let cfg = load_config(Some(tmp.path()))?;
131 assert_eq!(cfg.providers.len(), 2);
132 let claude = &cfg.providers[0];
133 assert_eq!(claude.key, "claude");
134 assert_eq!(claude.priority, Some(3));
135 assert_eq!(claude.models["plan"].model, "opus-4.7");
136 assert_eq!(claude.models["plan"].priority, None);
137 let codex = &cfg.providers[1];
138 assert_eq!(codex.models["plan"].priority, Some(5));
139 Ok(())
140 }
141
142 #[test]
143 fn rejects_provider_without_models() -> TestResult {
144 let yaml = "
145providers:
146 bare:
147 sdk: pi
148 models: {}
149";
150 let tmp = tempfile::NamedTempFile::new()?;
151 std::fs::write(tmp.path(), yaml)?;
152 let err = load_config(Some(tmp.path())).expect_err("should reject");
153 assert!(matches!(err, ConfigError::Invalid(_)));
154 Ok(())
155 }
156
157 #[test]
158 fn accepts_unknown_sdk_for_cross_impl_portability() -> TestResult {
159 let yaml = "
162providers:
163 claude:
164 sdk: claude
165 models:
166 build: opus-4.7
167 zai:
168 sdk: pi
169 models:
170 build: anthropic/zai-model
171";
172 let tmp = tempfile::NamedTempFile::new()?;
173 std::fs::write(tmp.path(), yaml)?;
174 let cfg = load_config(Some(tmp.path()))?;
175 assert_eq!(cfg.providers.len(), 2);
176 assert_eq!(cfg.providers[0].sdk, "claude");
177 assert_eq!(cfg.providers[1].sdk, "pi");
178 Ok(())
179 }
180}