Skip to main content

seher/sdk/
config_loader.rs

1//! Load `seher` YAML config from disk.
2//!
3//! Resolution order (TS parity):
4//!   1. `-c <path>` (caller-supplied)
5//!   2. `$SEHER_CONFIG`
6//!   3. `~/.config/seher/config.yaml`
7
8use 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
28/// Resolve the YAML config path using TS parity rules. Returns `None` when no
29/// path is supplied and the default `~/.config/seher/config.yaml` does not exist.
30///
31/// # Errors
32///
33/// Returns [`ConfigError::HomeNotSet`] when no override or `$SEHER_CONFIG` is set
34/// and the home directory cannot be determined.
35pub 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
52/// Load and normalize the YAML config from the resolved path. If no file is found,
53/// returns the default (empty) config — same as TS `loadConfig` returning the
54/// default empty config.
55///
56/// # Errors
57///
58/// Returns [`ConfigError`] on filesystem or parse failures, or on validation issues
59/// (e.g. provider entry without any models).
60pub 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    // Note: `entry.sdk` is *not* validated here. Unknown sdk values (e.g. the
76    // seher-ts-only `claude` / `codex` / `copilot` / ...) are accepted so the
77    // same `config.yaml` works in both implementations; the resolve engine
78    // filters non-executable entries out and warns the user once at startup.
79    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        // seher-ts entries (sdk: claude / codex / copilot / kimi / cursor / opencode)
160        // must parse without error; they are filtered out at resolve time.
161        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}