1use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{NewtError, Result};
12use crate::router::Tier;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(default)]
21pub struct Config {
22 pub backends: Vec<BackendConfig>,
24
25 pub providers: Vec<ProviderConfig>,
27
28 pub default_tier_order: Vec<Tier>,
31
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub dgx: Option<crate::dgx::DgxConfig>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct BackendConfig {
42 pub name: String,
43 pub endpoint: String,
44 pub model: String,
45 pub tiers: Vec<Tier>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProviderConfig {
51 pub name: String,
52 pub command: String,
53 #[serde(default)]
54 pub env_pass: Vec<String>,
55 pub tiers: Vec<Tier>,
56}
57
58impl Default for Config {
63 fn default() -> Self {
64 Self {
65 backends: vec![BackendConfig {
66 name: "ollama".into(),
67 endpoint: "http://127.0.0.1:11434".into(),
68 model: "llama3.1:8b".into(),
69 tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
70 }],
71 providers: Vec::new(),
72 default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
73 dgx: None,
74 }
75 }
76}
77
78impl Config {
83 pub fn load(path: &Path) -> Result<Self> {
85 let text = std::fs::read_to_string(path)?;
86 toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
87 }
88
89 pub fn resolve() -> Result<Self> {
99 let candidates = Self::candidate_paths();
100 for path in &candidates {
101 if path.is_file() {
102 return Self::load(path);
103 }
104 }
105 Ok(Self::default())
106 }
107
108 fn candidate_paths() -> Vec<PathBuf> {
110 let mut paths = Vec::new();
111
112 if let Ok(p) = std::env::var("NEWT_CONFIG") {
113 paths.push(PathBuf::from(p));
114 }
115
116 paths.push(PathBuf::from("./newt.toml"));
117
118 if let Some(home) = home_dir() {
119 paths.push(home.join(".newt").join("config.toml"));
120 }
121
122 paths.push(PathBuf::from("/etc/newt/config.toml"));
123 paths
124 }
125}
126
127fn home_dir() -> Option<PathBuf> {
133 std::env::var("HOME")
134 .or_else(|_| std::env::var("USERPROFILE"))
135 .ok()
136 .map(PathBuf::from)
137}
138
139#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::io::Write;
147 use tempfile::NamedTempFile;
148
149 #[test]
150 fn defaults_are_sensible() {
151 let cfg = Config::default();
152 assert_eq!(cfg.backends.len(), 1);
153 assert_eq!(cfg.providers.len(), 0);
154 assert_eq!(cfg.default_tier_order.len(), 4);
155 }
156
157 #[test]
158 fn load_happy_path() {
159 let toml_text = r#"
160[[backends]]
161name = "local-ollama"
162endpoint = "http://localhost:11434"
163model = "mistral:7b"
164tiers = ["FAST", "STANDARD"]
165
166[[providers]]
167name = "cloud"
168command = "newt-cloud-shim"
169env_pass = ["CLOUD_TOKEN"]
170tiers = ["COMPLEX", "REVIEW"]
171
172default_tier_order = ["FAST", "STANDARD", "COMPLEX", "REVIEW"]
173"#;
174 let mut f = NamedTempFile::new().unwrap();
175 f.write_all(toml_text.as_bytes()).unwrap();
176 f.flush().unwrap();
177
178 let cfg = Config::load(f.path()).unwrap();
179 assert_eq!(cfg.backends.len(), 1);
180 assert_eq!(cfg.backends[0].name, "local-ollama");
181 assert_eq!(cfg.backends[0].model, "mistral:7b");
182 assert_eq!(cfg.backends[0].tiers, vec![Tier::Fast, Tier::Standard]);
183 assert_eq!(cfg.providers.len(), 1);
184 assert_eq!(cfg.providers[0].name, "cloud");
185 assert_eq!(cfg.providers[0].env_pass, vec!["CLOUD_TOKEN".to_string()]);
186 }
187
188 #[test]
189 fn missing_file_returns_io_error() {
190 let result = Config::load(Path::new("/tmp/newt-does-not-exist-12345.toml"));
191 assert!(result.is_err());
192 let err = result.unwrap_err();
193 assert!(
194 matches!(err, NewtError::Io(_)),
195 "expected Io error, got: {err:?}"
196 );
197 }
198
199 #[test]
200 fn malformed_toml_returns_config_error() {
201 let mut f = NamedTempFile::new().unwrap();
202 f.write_all(b"{{{{").unwrap();
203 f.flush().unwrap();
204
205 let result = Config::load(f.path());
206 assert!(result.is_err());
207 let err = result.unwrap_err();
208 assert!(
209 matches!(err, NewtError::Config(_)),
210 "expected Config error, got: {err:?}"
211 );
212 }
213
214 #[test]
215 fn resolve_returns_default_when_no_file() {
216 let dir = tempfile::tempdir().unwrap();
218
219 let saved_config = std::env::var("NEWT_CONFIG").ok();
221 let saved_home = std::env::var("HOME").ok();
222 std::env::remove_var("NEWT_CONFIG");
223 std::env::set_var("HOME", dir.path());
224
225 let prev_dir = std::env::current_dir().unwrap();
227 std::env::set_current_dir(dir.path()).unwrap();
228
229 let cfg = Config::resolve().unwrap();
230
231 std::env::set_current_dir(prev_dir).unwrap();
233 if let Some(v) = saved_home {
234 std::env::set_var("HOME", v);
235 }
236 if let Some(v) = saved_config {
237 std::env::set_var("NEWT_CONFIG", v);
238 }
239
240 assert_eq!(cfg.backends.len(), 1);
241 assert_eq!(cfg.backends[0].name, "ollama");
242 }
243
244 #[test]
245 fn config_default_has_no_dgx() {
246 assert!(Config::default().dgx.is_none());
247 }
248
249 #[test]
250 fn config_with_dgx_roundtrips() {
251 let cfg = Config {
252 dgx: Some(crate::dgx::DgxConfig::home_template()),
253 ..Config::default()
254 };
255 let text = toml::to_string_pretty(&cfg).unwrap();
256 let back = toml::from_str::<Config>(&text).unwrap();
257 let dgx = back.dgx.expect("dgx should round-trip");
258 assert_eq!(dgx.active_node.as_deref(), Some("home"));
259 assert_eq!(dgx.nodes.len(), 1);
260 assert_eq!(dgx.formations.len(), 2);
261 }
262}