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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tui: Option<TuiConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<crate::pricing::PricingConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<MemoryConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
#[serde(default)]
pub provider: MemoryProviderKind,
#[serde(default = "default_memory_window")]
pub window: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub soul_file: Option<String>,
}
fn default_memory_window() -> usize {
20
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
provider: MemoryProviderKind::RollingWindow,
window: 20,
context_tokens: None,
soul_file: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryProviderKind {
#[default]
RollingWindow,
TokenBudget,
Summarizing,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiConfig {
pub chat_style: ChatStyle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default)]
pub no_splash: bool,
#[serde(default)]
pub edit_mode: EditMode,
#[serde(default = "default_tool_output_lines")]
pub tool_output_lines: usize,
#[serde(default)]
pub permissions: ToolPermissions,
}
fn default_tool_output_lines() -> usize {
20
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PermissionPreset {
ReadOnly,
WorkspaceEdit,
#[default]
WorkspaceDev,
FullAccess,
Custom,
}
impl PermissionPreset {
pub const ALL: [Self; 4] = [
Self::ReadOnly,
Self::WorkspaceEdit,
Self::WorkspaceDev,
Self::FullAccess,
];
pub fn as_str(&self) -> &'static str {
match self {
Self::ReadOnly => "read_only",
Self::WorkspaceEdit => "workspace_edit",
Self::WorkspaceDev => "workspace_dev",
Self::FullAccess => "full_access",
Self::Custom => "custom",
}
}
pub fn toggle(&self) -> Self {
let idx = Self::ALL.iter().position(|p| p == self).unwrap_or(2);
Self::ALL[(idx + 1) % Self::ALL.len()].clone()
}
pub fn description(&self) -> &'static str {
match self {
Self::ReadOnly => "read files + list dirs; no writes, no commands",
Self::WorkspaceEdit => "read + write workspace; no shell commands",
Self::WorkspaceDev => "read, write workspace, run: cargo just git grep rg fd ...",
Self::FullAccess => "unrestricted (prompts y/N before each write)",
Self::Custom => "workspace-dev tools plus your extra commands",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolPermissions {
pub preset: PermissionPreset,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_exec: Vec<String>,
}
impl Default for ToolPermissions {
fn default() -> Self {
Self {
preset: PermissionPreset::WorkspaceDev,
extra_exec: Vec::new(),
}
}
}
impl ToolPermissions {
const WORKSPACE_DEV_EXEC: &'static [&'static str] = &[
"cargo",
"just",
"git",
"grep",
"rg",
"ripgrep",
"fd",
"find",
"cat",
"ls",
"echo",
"pwd",
"true",
"false",
"head",
"tail",
"wc",
"sort",
"uniq",
"diff",
"patch",
"rustfmt",
"clippy-driver",
"rustup",
];
pub fn to_caveats(&self, workspace: &str) -> crate::caveats::Caveats {
use crate::caveats::{Caveats, CountBound, Scope};
let ws = workspace.to_string();
match self.preset {
PermissionPreset::ReadOnly => Caveats {
fs_read: Scope::All,
fs_write: Scope::none(),
exec: Scope::none(),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
},
PermissionPreset::WorkspaceEdit => Caveats {
fs_read: Scope::All,
fs_write: Scope::only([ws]),
exec: Scope::none(),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
},
PermissionPreset::WorkspaceDev | PermissionPreset::Custom => {
let mut allowed: std::collections::BTreeSet<String> = Self::WORKSPACE_DEV_EXEC
.iter()
.map(|s| s.to_string())
.collect();
for cmd in &self.extra_exec {
allowed.insert(cmd.clone());
}
Caveats {
fs_read: Scope::All,
fs_write: Scope::only([ws]),
exec: Scope::Only(allowed),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
PermissionPreset::FullAccess => Caveats::top(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EditMode {
#[default]
Emacs,
Vi,
}
impl EditMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Emacs => "emacs",
Self::Vi => "vi",
}
}
pub fn toggle(&self) -> Self {
match self {
Self::Emacs => Self::Vi,
Self::Vi => Self::Emacs,
}
}
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
chat_style: ChatStyle::Compact,
prompt: None,
no_splash: false,
edit_mode: EditMode::Emacs,
tool_output_lines: default_tool_output_lines(),
permissions: ToolPermissions::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ChatStyle {
#[default]
Compact,
Verbose,
}
impl ChatStyle {
pub fn as_str(&self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Verbose => "verbose",
}
}
pub fn toggle(&self) -> Self {
match self {
Self::Compact => Self::Verbose,
Self::Verbose => Self::Compact,
}
}
}
#[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,
tui: None,
pricing: None,
memory: 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())
}
pub fn user_config_path() -> Option<PathBuf> {
home_dir().map(|h| h.join(".newt").join("config.toml"))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
}
let text = toml::to_string_pretty(self).map_err(|e| NewtError::Config(e.to_string()))?;
std::fs::write(path, text).map_err(NewtError::Io)
}
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 crate::caveats::CaveatsExt;
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);
}
#[test]
fn workspace_dev_allows_cargo_and_just() {
let perms = ToolPermissions::default(); let cav = perms.to_caveats("/workspace");
assert!(cav.permits_exec("cargo"), "cargo must be allowed");
assert!(cav.permits_exec("just"), "just must be allowed");
assert!(cav.permits_exec("git"), "git must be allowed");
}
#[test]
fn workspace_dev_blocks_rm_and_mv() {
let perms = ToolPermissions::default();
let cav = perms.to_caveats("/workspace");
assert!(!cav.permits_exec("rm"), "rm must be blocked");
assert!(!cav.permits_exec("mv"), "mv must be blocked");
assert!(!cav.permits_exec("sudo"), "sudo must be blocked");
}
#[test]
fn workspace_dev_allows_extra_exec() {
let perms = ToolPermissions {
preset: PermissionPreset::WorkspaceDev,
extra_exec: vec!["bacon".into(), "make".into()],
};
let cav = perms.to_caveats("/workspace");
assert!(cav.permits_exec("bacon"));
assert!(cav.permits_exec("make"));
assert!(!cav.permits_exec("rm")); }
#[test]
fn read_only_blocks_writes_and_exec() {
let perms = ToolPermissions {
preset: PermissionPreset::ReadOnly,
extra_exec: vec![],
};
let cav = perms.to_caveats("/workspace");
assert!(!cav.permits_fs_write("/workspace/src/main.rs"));
assert!(!cav.permits_exec("cargo"));
assert!(cav.permits_fs_read("/workspace/src/main.rs"));
}
#[test]
fn workspace_edit_allows_write_blocks_exec() {
let perms = ToolPermissions {
preset: PermissionPreset::WorkspaceEdit,
extra_exec: vec![],
};
let cav = perms.to_caveats("/workspace");
assert!(!cav.permits_exec("cargo"));
use crate::caveats::Scope;
assert!(matches!(cav.fs_write, Scope::Only(_)));
}
#[test]
fn full_access_is_top() {
let perms = ToolPermissions {
preset: PermissionPreset::FullAccess,
extra_exec: vec![],
};
let cav = perms.to_caveats("/workspace");
assert_eq!(cav, crate::caveats::Caveats::top());
}
#[test]
fn custom_is_workspace_dev_not_top() {
let custom = ToolPermissions {
preset: PermissionPreset::Custom,
extra_exec: vec!["bacon".into()],
}
.to_caveats("/workspace");
assert_ne!(
custom,
crate::caveats::Caveats::top(),
"Custom must not be full access"
);
assert!(custom.permits_exec("cargo"), "workspace-dev tools allowed");
assert!(custom.permits_exec("bacon"), "extra_exec command allowed");
assert!(!custom.permits_exec("rm"), "non-allowlisted command denied");
let workspace_dev = ToolPermissions {
preset: PermissionPreset::WorkspaceDev,
extra_exec: vec!["bacon".into()],
}
.to_caveats("/workspace");
assert_eq!(
custom, workspace_dev,
"Custom carries WorkspaceDev authority + extras"
);
}
#[test]
fn preset_toggle_cycles() {
assert_eq!(
PermissionPreset::ReadOnly.toggle(),
PermissionPreset::WorkspaceEdit
);
assert_eq!(
PermissionPreset::WorkspaceEdit.toggle(),
PermissionPreset::WorkspaceDev
);
assert_eq!(
PermissionPreset::WorkspaceDev.toggle(),
PermissionPreset::FullAccess
);
assert_eq!(
PermissionPreset::FullAccess.toggle(),
PermissionPreset::ReadOnly
);
}
#[test]
fn tool_permissions_toml_roundtrip() {
let perms = ToolPermissions {
preset: PermissionPreset::WorkspaceDev,
extra_exec: vec!["bacon".into()],
};
let toml = toml::to_string(&perms).unwrap();
assert!(toml.contains("workspace_dev"));
assert!(toml.contains("bacon"));
let back: ToolPermissions = toml::from_str(&toml).unwrap();
assert_eq!(back, perms);
}
}