use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{NewtError, Result};
use crate::router::Tier;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub backends: Vec<BackendConfig>,
pub providers: Vec<ProviderConfig>,
pub default_tier_order: Vec<Tier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dgx: Option<crate::dgx::DgxConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendConfig {
pub name: String,
pub endpoint: String,
pub model: String,
pub tiers: Vec<Tier>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub name: String,
pub command: String,
#[serde(default)]
pub env_pass: Vec<String>,
pub tiers: Vec<Tier>,
}
impl Default for Config {
fn default() -> Self {
Self {
backends: vec![BackendConfig {
name: "ollama".into(),
endpoint: "http://127.0.0.1:11434".into(),
model: "llama3.1:8b".into(),
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
}],
providers: Vec::new(),
default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
dgx: None,
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)?;
toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
}
pub fn resolve() -> Result<Self> {
let candidates = Self::candidate_paths();
for path in &candidates {
if path.is_file() {
return Self::load(path);
}
}
Ok(Self::default())
}
fn candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(p) = std::env::var("NEWT_CONFIG") {
paths.push(PathBuf::from(p));
}
paths.push(PathBuf::from("./newt.toml"));
if let Some(home) = home_dir() {
paths.push(home.join(".newt").join("config.toml"));
}
paths.push(PathBuf::from("/etc/newt/config.toml"));
paths
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn defaults_are_sensible() {
let cfg = Config::default();
assert_eq!(cfg.backends.len(), 1);
assert_eq!(cfg.providers.len(), 0);
assert_eq!(cfg.default_tier_order.len(), 4);
}
#[test]
fn load_happy_path() {
let toml_text = r#"
[[backends]]
name = "local-ollama"
endpoint = "http://localhost:11434"
model = "mistral:7b"
tiers = ["FAST", "STANDARD"]
[[providers]]
name = "cloud"
command = "newt-cloud-shim"
env_pass = ["CLOUD_TOKEN"]
tiers = ["COMPLEX", "REVIEW"]
default_tier_order = ["FAST", "STANDARD", "COMPLEX", "REVIEW"]
"#;
let mut f = NamedTempFile::new().unwrap();
f.write_all(toml_text.as_bytes()).unwrap();
f.flush().unwrap();
let cfg = Config::load(f.path()).unwrap();
assert_eq!(cfg.backends.len(), 1);
assert_eq!(cfg.backends[0].name, "local-ollama");
assert_eq!(cfg.backends[0].model, "mistral:7b");
assert_eq!(cfg.backends[0].tiers, vec![Tier::Fast, Tier::Standard]);
assert_eq!(cfg.providers.len(), 1);
assert_eq!(cfg.providers[0].name, "cloud");
assert_eq!(cfg.providers[0].env_pass, vec!["CLOUD_TOKEN".to_string()]);
}
#[test]
fn missing_file_returns_io_error() {
let result = Config::load(Path::new("/tmp/newt-does-not-exist-12345.toml"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, NewtError::Io(_)),
"expected Io error, got: {err:?}"
);
}
#[test]
fn malformed_toml_returns_config_error() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"{{{{").unwrap();
f.flush().unwrap();
let result = Config::load(f.path());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, NewtError::Config(_)),
"expected Config error, got: {err:?}"
);
}
#[test]
fn resolve_returns_default_when_no_file() {
let dir = tempfile::tempdir().unwrap();
let saved_config = std::env::var("NEWT_CONFIG").ok();
let saved_home = std::env::var("HOME").ok();
std::env::remove_var("NEWT_CONFIG");
std::env::set_var("HOME", dir.path());
let prev_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let cfg = Config::resolve().unwrap();
std::env::set_current_dir(prev_dir).unwrap();
if let Some(v) = saved_home {
std::env::set_var("HOME", v);
}
if let Some(v) = saved_config {
std::env::set_var("NEWT_CONFIG", v);
}
assert_eq!(cfg.backends.len(), 1);
assert_eq!(cfg.backends[0].name, "ollama");
}
#[test]
fn config_default_has_no_dgx() {
assert!(Config::default().dgx.is_none());
}
#[test]
fn config_with_dgx_roundtrips() {
let cfg = Config {
dgx: Some(crate::dgx::DgxConfig::home_template()),
..Config::default()
};
let text = toml::to_string_pretty(&cfg).unwrap();
let back = toml::from_str::<Config>(&text).unwrap();
let dgx = back.dgx.expect("dgx should round-trip");
assert_eq!(dgx.active_node.as_deref(), Some("home"));
assert_eq!(dgx.nodes.len(), 1);
assert_eq!(dgx.formations.len(), 2);
}
}