Skip to main content

newt_core/
config.rs

1//! Configuration loading for Newt-Agent.
2//!
3//! Resolution order: `$NEWT_CONFIG` env var, then `./newt.toml`,
4//! `~/.newt/config.toml`, `/etc/newt/config.toml`. If none exist the
5//! built-in defaults are used (a single Ollama backend on localhost).
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{NewtError, Result};
12use crate::router::Tier;
13
14// ---------------------------------------------------------------------------
15// Config types
16// ---------------------------------------------------------------------------
17
18/// Top-level Newt-Agent configuration.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(default)]
21pub struct Config {
22    /// Inference backends (Ollama, vLLM, etc.).
23    pub backends: Vec<BackendConfig>,
24
25    /// External provider-plugin definitions.
26    pub providers: Vec<ProviderConfig>,
27
28    /// Default tier ordering used by the router when no per-backend
29    /// override is specified.
30    pub default_tier_order: Vec<Tier>,
31
32    /// Optional NVIDIA DGX endpoint-management config powering the
33    /// `newt dgx` command suite. `None` when unconfigured — newt never
34    /// dials a DGX endpoint unless this (or a `NEWT_DGX_*` env var) is set.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub dgx: Option<crate::dgx::DgxConfig>,
37}
38
39/// A single inference backend entry.
40#[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/// A subprocess provider-plugin entry.
49#[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
58// ---------------------------------------------------------------------------
59// Default
60// ---------------------------------------------------------------------------
61
62impl 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
78// ---------------------------------------------------------------------------
79// Loading
80// ---------------------------------------------------------------------------
81
82impl Config {
83    /// Load configuration from an explicit file path.
84    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    /// Resolve configuration by searching well-known locations.
90    ///
91    /// Search order:
92    /// 1. `$NEWT_CONFIG` environment variable
93    /// 2. `./newt.toml`
94    /// 3. `~/.newt/config.toml`
95    /// 4. `/etc/newt/config.toml`
96    ///
97    /// Returns `Config::default()` if none of the candidates exist.
98    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    /// Build the ordered list of candidate config file paths.
109    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
127// ---------------------------------------------------------------------------
128// Helpers
129// ---------------------------------------------------------------------------
130
131/// Best-effort home directory lookup without pulling in the `dirs` crate.
132fn 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// ---------------------------------------------------------------------------
140// Tests
141// ---------------------------------------------------------------------------
142
143#[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        // Use a temp dir as cwd and clear env to ensure no candidates match.
217        let dir = tempfile::tempdir().unwrap();
218
219        // Save & clear environment to isolate the test.
220        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        // Run resolve from inside the temp dir so ./newt.toml won't exist.
226        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        // Restore environment.
232        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}