use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{NewtError, Result};
use crate::router::Tier;
pub use newt_tuner::ModelTuning;
pub use tool_exposure::{ExposureProfile, ToolExposureConfig};
pub use tools::ToolsConfig;
mod shell;
mod tool_exposure;
mod tools;
pub use shell::{
confined_default_engine, full_access_default_engine, mcp_stdio_env_passthrough,
ocap_l3_backend, resolve_shell_engine, resolve_shell_engine_choice, resolved_confined_default,
shell_env_passthrough_default, IntakeConfig, ShellConfig, ShellEngine,
};
use tools::{
default_max_output_tokens, default_output_cap_chars_per_token, default_output_head_tokens,
};
pub const NEWT_CONFIG_DIR_ENV: &str = "NEWT_CONFIG_DIR";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScratchConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(default = "Vec::new")]
pub backends: Vec<BackendConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_backend: Option<String>,
#[serde(default)]
pub discovery: Discovery,
pub providers: Vec<ProviderConfig>,
#[serde(default)]
pub scratch: Option<ScratchConfig>,
pub default_tier_order: Vec<Tier>,
#[serde(default)]
pub lifecycle: Option<crate::tooling::PhaseCommands>,
#[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 shell: Option<ShellConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intake: Option<IntakeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<ContextConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenacity: Option<crate::tenacity::TenacityConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_exposure: Option<ToolExposureConfig>,
#[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>,
#[serde(default)]
pub agents: AgentsConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<crate::mcp::McpServerEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logs: Option<LogConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<SkillsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub model_tuning: Vec<ModelTuning>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversations: Option<ConversationsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge: Option<MergeConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub permission_presets: std::collections::BTreeMap<String, crate::NamedPermissionPreset>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub modes: std::collections::BTreeMap<String, ModeConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub profiles: std::collections::BTreeMap<String, ProfileConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub bundles: std::collections::BTreeMap<String, BundleConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub loadouts: std::collections::BTreeMap<String, Loadout>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub crews: std::collections::BTreeMap<String, Crew>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crew: Option<CrewPolicyConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<PlanConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framing: Option<String>,
}
pub const KNOWN_TECHNIQUES: &[&str] = &[
"knowledge_base", "verify_gate", "retry", ];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ProfileConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub techniques: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_gate: Option<VerifyGateKnobs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry: Option<RetryKnobs>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct VerifyGateKnobs {
#[serde(default)]
pub surface_match: crate::verify_gate::SurfaceMatch,
#[serde(default)]
pub tier: crate::verify_gate::VerifyTier,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryKnobs {
#[serde(default = "default_max_retries")]
pub max_retries: u32,
}
const fn default_max_retries() -> u32 {
2
}
impl Default for RetryKnobs {
fn default() -> Self {
Self {
max_retries: default_max_retries(),
}
}
}
impl ProfileConfig {
pub fn validate(&self) -> std::result::Result<(), String> {
for t in &self.techniques {
let Some(entry) = crate::kit::component(t) else {
return Err(format!(
"unknown technique '{t}' in profile (known: {})",
KNOWN_TECHNIQUES.join(", ")
));
};
for pre in entry.presupposes {
if !self.techniques.iter().any(|x| x == pre) {
return Err(format!(
"technique '{t}' presupposes '{pre}', which the profile does not enable"
));
}
}
}
Ok(())
}
#[must_use]
pub fn enables(&self, technique: &str) -> bool {
self.techniques.iter().any(|t| t == technique)
}
#[must_use]
pub fn verify_gate_knobs(&self) -> VerifyGateKnobs {
self.verify_gate.unwrap_or_default()
}
#[must_use]
pub fn retry_knobs(&self) -> RetryKnobs {
self.retry.unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArrayMergeStrategy {
#[default]
Replace,
Append,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct MergeConfig {
#[serde(default)]
pub arrays: ArrayMergeStrategy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ConversationsConfig {
#[serde(default = "default_conversations_max_per_workspace")]
pub max_per_workspace: usize,
#[serde(default = "default_conversations_resume")]
pub resume: bool,
}
fn default_conversations_max_per_workspace() -> usize {
100
}
fn default_conversations_resume() -> bool {
false
}
impl Default for ConversationsConfig {
fn default() -> Self {
Self {
max_per_workspace: default_conversations_max_per_workspace(),
resume: default_conversations_resume(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SkillsConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub search: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub bundled_dir: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LogConfig {
#[serde(default = "default_log_max_sessions")]
pub max_sessions: usize,
#[serde(default)]
pub max_size_mb: u64,
#[serde(default)]
pub max_age_days: u64,
#[serde(default = "default_log_keep_rotated")]
pub keep_rotated: usize,
}
fn default_log_max_sessions() -> usize {
7
}
fn default_log_keep_rotated() -> usize {
3
}
impl Default for LogConfig {
fn default() -> Self {
Self {
max_sessions: default_log_max_sessions(),
max_size_mb: 0,
max_age_days: 0,
keep_rotated: default_log_keep_rotated(),
}
}
}
#[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>,
#[serde(default = "default_note_nudge_interval")]
pub note_nudge_interval: usize,
#[serde(default)]
pub extract_notes_on_close: bool,
#[serde(default)]
pub disclosure: MemoryDisclosure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryDisclosure {
#[default]
Frozen,
Index,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FooterMode {
#[default]
Auto,
On,
Off,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ColorMode {
#[default]
Auto,
Always,
Never,
Minimal,
Inverted,
Dark,
Light,
Mono,
}
impl ColorMode {
pub fn from_keyword(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"always" | "on" => Some(Self::Always),
"never" | "off" => Some(Self::Never),
"minimal" => Some(Self::Minimal),
"inverted" => Some(Self::Inverted),
"dark" => Some(Self::Dark),
"light" => Some(Self::Light),
"mono" | "monochrome" => Some(Self::Mono),
_ => None,
}
}
pub fn keyword(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Always => "always",
Self::Never => "never",
Self::Minimal => "minimal",
Self::Inverted => "inverted",
Self::Dark => "dark",
Self::Light => "light",
Self::Mono => "mono",
}
}
pub fn forced(self) -> Option<bool> {
match self {
Self::Always | Self::Minimal | Self::Inverted | Self::Dark | Self::Light => Some(true),
Self::Never | Self::Mono => Some(false),
Self::Auto => None,
}
}
pub fn is_mono(self) -> bool {
matches!(self, Self::Mono)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MarkdownMode {
#[default]
Auto,
On,
Off,
}
impl MarkdownMode {
pub fn from_keyword(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"on" | "always" => Some(Self::On),
"off" | "never" => Some(Self::Off),
_ => None,
}
}
pub fn keyword(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::On => "on",
Self::Off => "off",
}
}
pub fn forced(self) -> Option<bool> {
match self {
Self::On => Some(true),
Self::Off => Some(false),
Self::Auto => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ContextManager {
#[default]
Standard,
Progressive,
Distributed,
}
impl ContextManager {
pub fn from_keyword(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"standard" => Some(Self::Standard),
"progressive" => Some(Self::Progressive),
"distributed" => Some(Self::Distributed),
_ => None,
}
}
pub fn keyword(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Progressive => "progressive",
Self::Distributed => "distributed",
}
}
pub fn available(self) -> bool {
matches!(self, Self::Standard)
}
pub fn base_features(self) -> ContextFeatureSet {
ContextFeatureSet::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CompactionTriggerPolicy {
#[default]
HeadroomAware,
MessageCount,
}
impl CompactionTriggerPolicy {
pub fn from_keyword(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"headroom_aware" => Some(Self::HeadroomAware),
"message_count" => Some(Self::MessageCount),
_ => None,
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::HeadroomAware => "headroom_aware",
Self::MessageCount => "message_count",
}
}
pub const fn keyword(self) -> &'static str {
self.as_str()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextFeature {
ToolOffload,
Scratchpad,
Semantic,
Provenance,
Experiential,
Scheduled,
}
impl ContextFeature {
pub const ALL: [Self; 6] = [
Self::ToolOffload,
Self::Scratchpad,
Self::Semantic,
Self::Provenance,
Self::Experiential,
Self::Scheduled,
];
pub fn from_keyword(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"tool_offload" | "tooloffload" | "offload" => Some(Self::ToolOffload),
"scratchpad" | "state" => Some(Self::Scratchpad),
"semantic" | "retrieval" => Some(Self::Semantic),
"provenance" | "handles" => Some(Self::Provenance),
"experiential" | "experience" => Some(Self::Experiential),
"scheduled" | "compiled" => Some(Self::Scheduled),
_ => None,
}
}
pub fn keyword(self) -> &'static str {
match self {
Self::ToolOffload => "tool_offload",
Self::Scratchpad => "scratchpad",
Self::Semantic => "semantic",
Self::Provenance => "provenance",
Self::Experiential => "experiential",
Self::Scheduled => "scheduled",
}
}
pub fn available(self) -> bool {
match self {
Self::ToolOffload
| Self::Scratchpad
| Self::Semantic
| Self::Experiential
| Self::Scheduled => true,
Self::Provenance => false,
}
}
pub fn issue(self) -> u32 {
match self {
Self::ToolOffload | Self::Provenance => 584,
Self::Scratchpad => 583,
Self::Semantic => 582,
Self::Experiential => 585,
Self::Scheduled => 586,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ContextFeatureSet {
pub tool_offload: bool,
pub scratchpad: bool,
pub semantic: bool,
pub provenance: bool,
pub experiential: bool,
pub scheduled: bool,
}
impl ContextFeatureSet {
pub fn get(&self, f: ContextFeature) -> bool {
match f {
ContextFeature::ToolOffload => self.tool_offload,
ContextFeature::Scratchpad => self.scratchpad,
ContextFeature::Semantic => self.semantic,
ContextFeature::Provenance => self.provenance,
ContextFeature::Experiential => self.experiential,
ContextFeature::Scheduled => self.scheduled,
}
}
pub fn set(&mut self, f: ContextFeature, on: bool) {
match f {
ContextFeature::ToolOffload => self.tool_offload = on,
ContextFeature::Scratchpad => self.scratchpad = on,
ContextFeature::Semantic => self.semantic = on,
ContextFeature::Provenance => self.provenance = on,
ContextFeature::Experiential => self.experiential = on,
ContextFeature::Scheduled => self.scheduled = on,
}
}
pub fn enabled(self) -> Vec<ContextFeature> {
ContextFeature::ALL
.into_iter()
.filter(|&f| self.get(f))
.collect()
}
pub fn base_for(manager: ContextManager, _kind: BackendKind) -> Self {
let mut base = manager.base_features();
for f in ContextFeature::ALL {
if f != ContextFeature::Provenance && f.available() {
base.set(f, true);
}
}
base
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextFeatures {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_offload: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scratchpad: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub experiential: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduled: Option<bool>,
}
impl ContextFeatures {
pub fn get(&self, f: ContextFeature) -> Option<bool> {
match f {
ContextFeature::ToolOffload => self.tool_offload,
ContextFeature::Scratchpad => self.scratchpad,
ContextFeature::Semantic => self.semantic,
ContextFeature::Provenance => self.provenance,
ContextFeature::Experiential => self.experiential,
ContextFeature::Scheduled => self.scheduled,
}
}
pub fn set(&mut self, f: ContextFeature, v: Option<bool>) {
match f {
ContextFeature::ToolOffload => self.tool_offload = v,
ContextFeature::Scratchpad => self.scratchpad = v,
ContextFeature::Semantic => self.semantic = v,
ContextFeature::Provenance => self.provenance = v,
ContextFeature::Experiential => self.experiential = v,
ContextFeature::Scheduled => self.scheduled = v,
}
}
pub fn apply_to(&self, mut base: ContextFeatureSet) -> ContextFeatureSet {
for f in ContextFeature::ALL {
if let Some(v) = self.get(f) {
base.set(f, v);
}
}
base
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextConfig {
#[serde(default)]
pub manager: ContextManager,
#[serde(default)]
pub compaction_trigger_policy: CompactionTriggerPolicy,
#[serde(default)]
pub features: ContextFeatures,
#[serde(default)]
pub semantic: SemanticConfig,
#[serde(default)]
pub estimation: crate::tokens::TokenEstimation,
#[serde(default = "default_summary_input_cap_floor_chars")]
pub summary_input_cap_floor_chars: usize,
#[serde(default)]
pub api_surface: ApiSurfaceConfig,
#[serde(default = "default_input_ceiling_pct")]
pub input_ceiling_pct: u32,
#[serde(default = "default_low_budget_pct")]
pub low_budget_pct: usize,
}
fn default_summary_input_cap_floor_chars() -> usize {
8_192
}
fn default_input_ceiling_pct() -> u32 {
80
}
fn default_low_budget_pct() -> usize {
15
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
manager: ContextManager::default(),
compaction_trigger_policy: CompactionTriggerPolicy::default(),
features: ContextFeatures::default(),
semantic: SemanticConfig::default(),
estimation: crate::tokens::TokenEstimation::default(),
summary_input_cap_floor_chars: default_summary_input_cap_floor_chars(),
api_surface: ApiSurfaceConfig::default(),
input_ceiling_pct: default_input_ceiling_pct(),
low_budget_pct: default_low_budget_pct(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolRule {
pub pattern: String,
pub kind: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LanguagePack {
pub name: String,
#[serde(default)]
pub aliases: Vec<String>,
pub extensions: Vec<String>,
#[serde(default)]
pub entry_points: Vec<String>,
pub symbols: Vec<SymbolRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiSurfaceConfig {
#[serde(default)]
pub language_packs: Vec<LanguagePack>,
#[serde(default)]
pub max_block_chars: Option<usize>,
#[serde(default = "default_api_surface_floor_chars")]
pub floor_chars: usize,
#[serde(default = "default_api_surface_pct_of_budget")]
pub pct_of_budget: usize,
#[serde(default = "default_api_surface_ceiling_chars")]
pub ceiling_chars: usize,
#[serde(default = "default_api_surface_max_symbols_per_file")]
pub max_symbols_per_file: usize,
}
impl Default for ApiSurfaceConfig {
fn default() -> Self {
Self {
language_packs: Vec::new(),
max_block_chars: None,
floor_chars: default_api_surface_floor_chars(),
pct_of_budget: default_api_surface_pct_of_budget(),
ceiling_chars: default_api_surface_ceiling_chars(),
max_symbols_per_file: default_api_surface_max_symbols_per_file(),
}
}
}
fn default_api_surface_floor_chars() -> usize {
2_000
}
fn default_api_surface_pct_of_budget() -> usize {
5
}
fn default_api_surface_ceiling_chars() -> usize {
24_000
}
fn default_api_surface_max_symbols_per_file() -> usize {
12
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SemanticConfig {
#[serde(default = "default_embedding_model")]
pub embedding_model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding_model_path: Option<String>,
#[serde(default = "default_semantic_top_k")]
pub top_k: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embeddings_endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embeddings_api: Option<BackendKind>,
#[serde(default)]
pub on_embed_failure: OnEmbedFailure,
}
impl Default for SemanticConfig {
fn default() -> Self {
Self {
embedding_model: default_embedding_model(),
embedding_model_path: None,
top_k: default_semantic_top_k(),
embeddings_endpoint: None,
embeddings_api: None,
on_embed_failure: OnEmbedFailure::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum OnEmbedFailure {
#[default]
Disable,
Warn,
}
fn default_embedding_model() -> String {
"nomic-embed-text".to_string()
}
fn default_semantic_top_k() -> usize {
5
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ThinkingMode {
#[default]
Stream,
Off,
}
fn default_memory_window() -> usize {
20
}
fn default_note_nudge_interval() -> usize {
10
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
provider: MemoryProviderKind::RollingWindow,
window: 20,
context_tokens: None,
soul_file: None,
note_nudge_interval: 10,
extract_notes_on_close: false,
disclosure: MemoryDisclosure::Frozen,
}
}
}
#[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 AgentsConfig {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
impl Default for AgentsConfig {
fn default() -> Self {
Self {
enabled: true,
path: None,
}
}
}
#[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 mouse_viewport: bool,
#[serde(default)]
pub edit_mode: EditMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gutter: Option<u16>,
#[serde(default)]
pub footer: FooterMode,
#[serde(default)]
pub color: ColorMode,
#[serde(default)]
pub thinking: ThinkingMode,
#[serde(default = "default_tool_output_lines")]
pub tool_output_lines: usize,
#[serde(default = "default_spill_lines")]
pub spill_lines: usize,
#[serde(default = "default_max_tool_rounds")]
pub max_tool_rounds: usize,
#[serde(default = "default_workflow_grace_rounds")]
pub workflow_grace_rounds: usize,
#[serde(default = "default_narration_nudge_cap")]
pub narration_nudge_cap: usize,
#[serde(default)]
pub permissions: ToolPermissions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debug: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_check_cmd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub num_ctx: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub real_context_discovery: Option<bool>,
#[serde(default = "default_connect_timeout_secs")]
pub connect_timeout_secs: u64,
#[serde(default = "default_inference_timeout_secs")]
pub inference_timeout_secs: u64,
#[serde(default = "default_keep_alive")]
pub keep_alive: String,
#[serde(default)]
pub markdown: MarkdownMode,
#[serde(default = "default_mid_loop_trim_threshold")]
pub mid_loop_trim_threshold: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mid_loop_trim_tokens: Option<usize>,
#[serde(default = "default_sanitize_mcp_server_names")]
pub sanitize_mcp_server_names: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_allow_insecure_hosts: Vec<String>,
#[serde(default = "default_allow_bang_escape")]
pub allow_bang_escape: bool,
#[serde(default = "default_allow_shell_commands")]
pub allow_shell_commands: bool,
#[serde(default = "default_allow_shell_mutations")]
pub allow_shell_mutations: bool,
}
fn default_spill_lines() -> usize {
3
}
fn default_tool_output_lines() -> usize {
20
}
fn default_max_tool_rounds() -> usize {
40
}
fn default_workflow_grace_rounds() -> usize {
5
}
fn default_narration_nudge_cap() -> usize {
1
}
fn default_connect_timeout_secs() -> u64 {
5
}
fn default_inference_timeout_secs() -> u64 {
120
}
fn default_keep_alive() -> String {
"5m".to_string()
}
fn default_summarizer_timeout_secs() -> u64 {
60
}
fn default_summarizer_retries() -> u32 {
1
}
fn default_mid_loop_trim_threshold() -> usize {
40
}
fn default_sanitize_mcp_server_names() -> bool {
true
}
fn default_allow_bang_escape() -> bool {
true
}
fn default_allow_shell_commands() -> bool {
true
}
fn default_allow_shell_mutations() -> bool {
false
}
impl Config {
#[must_use]
pub fn find_model_tuning(&self, name: &str) -> Option<&ModelTuning> {
self.model_tuning.iter().find(|t| t.model == name)
}
pub fn resolve_profile(&self, name: &str) -> std::result::Result<&ProfileConfig, String> {
let profile = self.profiles.get(name).ok_or_else(|| {
let known = if self.profiles.is_empty() {
"none defined".to_string()
} else {
self.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
};
format!("no such profile (known: {known})")
})?;
profile.validate()?;
Ok(profile)
}
pub fn resolve_bundle(&self, name: &str) -> std::result::Result<&BundleConfig, String> {
self.bundles.get(name).ok_or_else(|| {
let known = if self.bundles.is_empty() {
"none defined".to_string()
} else {
self.bundles.keys().cloned().collect::<Vec<_>>().join(", ")
};
format!("no such bundle (known: {known})")
})
}
#[must_use]
pub fn bundle_profile_for_model<'a>(
&self,
bundle: &'a BundleConfig,
model: &str,
) -> Option<&'a str> {
bundle
.families
.iter()
.filter(|(prefix, _)| model.starts_with(prefix.as_str()))
.max_by_key(|(prefix, _)| prefix.len()) .map(|(_, p)| p.as_str())
.or(bundle.default_profile.as_deref())
}
#[must_use]
pub fn infer_bundle(&self, model: &str) -> Option<(&str, &BundleConfig)> {
self.bundles
.iter()
.filter_map(|(name, b)| {
b.applies_to
.iter()
.filter(|p| model.starts_with(p.as_str()))
.map(String::len)
.max()
.map(|best| (best, name.as_str(), b))
})
.max_by_key(|(best, _, _)| *best)
.map(|(_, name, b)| (name, b))
}
pub fn pick_active_profile(
&self,
profile_flag: Option<&str>,
bundle_flag: Option<&str>,
model: &str,
) -> std::result::Result<Option<ProfilePick>, String> {
if let Some(p) = profile_flag.filter(|s| !s.is_empty()) {
return Ok(Some(ProfilePick {
name: p.to_string(),
via: PickVia::Profile,
}));
}
if let Some(b) = bundle_flag.filter(|s| !s.is_empty()) {
let bundle = self.resolve_bundle(b)?;
return Ok(self
.bundle_profile_for_model(bundle, model)
.map(|p| ProfilePick {
name: p.to_string(),
via: PickVia::Bundle(b.to_string()),
}));
}
if let Some((name, bundle)) = self.infer_bundle(model) {
return Ok(self
.bundle_profile_for_model(bundle, model)
.map(|p| ProfilePick {
name: p.to_string(),
via: PickVia::InferredBundle(name.to_string()),
}));
}
Ok(None)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct BundleConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applies_to: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_profile: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub families: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfilePick {
pub name: String,
pub via: PickVia,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PickVia {
Profile,
Bundle(String),
InferredBundle(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Loadout {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settings: Option<LoadoutSettings>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct LoadoutSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub num_ctx: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framing: Option<String>,
}
impl Loadout {
pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
if let Some(kit) = &self.kit {
cfg.resolve_bundle(kit)
.map_err(|e| format!("loadout kit '{kit}': {e}"))?;
}
if let Some(profile) = &self.profile {
cfg.resolve_profile(profile)
.map_err(|e| format!("loadout profile '{profile}': {e}"))?;
}
if let Some(provider) = &self.provider {
if !cfg.backends.iter().any(|b| &b.name == provider) {
let known = if cfg.backends.is_empty() {
"none defined".to_string()
} else {
cfg.backends
.iter()
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
return Err(format!(
"loadout provider '{provider}': no [backends] entry named '{provider}' (known: {known})"
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Crew {
pub planner: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub navigator: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub triage: Option<String>,
#[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
pub loop_program: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budgets: Option<CrewBudgets>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanConfig {
#[serde(default)]
pub prune: PlanPruneConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanPruneConfig {
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub add_inspect: Vec<String>,
#[serde(default)]
pub add_gate: Vec<String>,
#[serde(default)]
pub remove: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CrewPolicyConfig {
#[serde(default)]
pub clamp: crate::caveats::Caveats,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CrewBudgets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_files_touched: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_lines_changed: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub require_human_review_on: Vec<String>,
}
impl Crew {
pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
let check = |label: &str, name: &str| -> std::result::Result<(), String> {
let loadout = cfg.loadouts.get(name).ok_or_else(|| {
let known = if cfg.loadouts.is_empty() {
"none defined".to_string()
} else {
cfg.loadouts.keys().cloned().collect::<Vec<_>>().join(", ")
};
format!(
"crew {label} '{name}': no [loadouts] entry named '{name}' (known: {known})"
)
})?;
loadout
.validate(cfg)
.map_err(|e| format!("crew {label} '{name}': {e}"))
};
check("planner", &self.planner)?;
if let Some(nav) = &self.navigator {
check("navigator", nav)?;
}
if let Some(tri) = &self.triage {
check("triage", tri)?;
}
Ok(())
}
}
#[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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub net: Vec<String>,
#[serde(default)]
pub prompt: bool,
}
impl Default for ToolPermissions {
fn default() -> Self {
Self {
preset: PermissionPreset::WorkspaceDev,
extra_exec: Vec::new(),
net: Vec::new(),
prompt: false,
}
}
}
impl ToolPermissions {
const WORKSPACE_DEV_EXEC: &'static [&'static str] = &[
"cargo",
"rustc",
"just",
"git",
"grep",
"rg",
"ripgrep",
"fd",
"find",
"cat",
"ls",
"echo",
"pwd",
"true",
"false",
"head",
"tail",
"wc",
"sort",
"uniq",
"diff",
"patch",
"rustfmt",
"clippy-driver",
"rustup",
"gh",
"python",
"python3",
"pip",
"npm",
"node",
"make",
"jq",
"curl",
"awk",
"sed",
"cut",
"xargs",
"which",
"env",
];
pub fn to_caveats(&self, workspace: &str) -> crate::caveats::Caveats {
use crate::caveats::{Caveats, CountBound, Scope};
let ws = workspace.to_string();
let net = self.net_scope();
match self.preset {
PermissionPreset::ReadOnly => Caveats {
fs_read: Scope::All,
fs_write: Scope::none(),
exec: Scope::none(),
net,
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,
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,
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
PermissionPreset::FullAccess => Caveats::top(),
}
}
fn net_scope(&self) -> crate::caveats::Scope<String> {
use crate::caveats::Scope;
if self.net.is_empty() {
Scope::none()
} else if self.net.iter().any(|h| h == "*") {
Scope::All
} else {
Scope::only(self.net.iter().cloned())
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum EditMode {
Emacs,
Vi,
#[default]
Nano,
}
impl EditMode {
pub fn as_str(&self) -> &'static str {
match self {
Self::Emacs => "emacs",
Self::Vi => "vi",
Self::Nano => "nano",
}
}
pub fn toggle(&self) -> Self {
match self {
Self::Emacs => Self::Vi,
Self::Vi => Self::Nano,
Self::Nano => Self::Emacs,
}
}
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
chat_style: ChatStyle::Compact,
prompt: None,
no_splash: false,
mouse_viewport: false,
edit_mode: EditMode::Nano,
gutter: None,
footer: FooterMode::Auto,
color: ColorMode::Auto,
thinking: ThinkingMode::Stream,
tool_output_lines: default_tool_output_lines(),
spill_lines: default_spill_lines(),
max_tool_rounds: default_max_tool_rounds(),
workflow_grace_rounds: default_workflow_grace_rounds(),
narration_nudge_cap: default_narration_nudge_cap(),
permissions: ToolPermissions::default(),
debug: None,
trace: None,
build_check_cmd: None,
num_ctx: None,
real_context_discovery: None,
connect_timeout_secs: default_connect_timeout_secs(),
inference_timeout_secs: default_inference_timeout_secs(),
keep_alive: default_keep_alive(),
markdown: MarkdownMode::default(),
mid_loop_trim_threshold: default_mid_loop_trim_threshold(),
mid_loop_trim_tokens: None,
sanitize_mcp_server_names: default_sanitize_mcp_server_names(),
mcp_allow_insecure_hosts: Vec::new(),
allow_bang_escape: default_allow_bang_escape(),
allow_shell_commands: default_allow_shell_commands(),
allow_shell_mutations: default_allow_shell_mutations(),
}
}
}
#[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, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BackendKind {
#[default]
Ollama,
#[serde(alias = "vllm", alias = "openai-compatible")]
Openai,
Embedded,
}
impl BackendKind {
pub fn label(self) -> &'static str {
match self {
Self::Ollama => "ollama",
Self::Openai => "openai",
Self::Embedded => "embedded",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OpenAiApi {
#[default]
#[serde(alias = "chat", alias = "completions")]
ChatCompletions,
Responses,
}
impl OpenAiApi {
pub fn label(self) -> &'static str {
match self {
Self::ChatCompletions => "chat_completions",
Self::Responses => "responses",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Serving {
Multiplexer,
Instance,
}
pub fn derive_serving(kind: BackendKind, served_count: usize) -> Serving {
match kind {
BackendKind::Ollama => Serving::Multiplexer,
BackendKind::Openai => {
if served_count == 1 {
Serving::Instance
} else {
Serving::Multiplexer
}
}
BackendKind::Embedded => Serving::Instance,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Discovery {
pub hosts: Vec<String>,
pub ollama_ports: Vec<u16>,
pub vllm_ports: Vec<u16>,
}
impl Default for Discovery {
fn default() -> Self {
Self {
hosts: vec!["localhost".into()],
ollama_ports: vec![11434],
vllm_ports: vec![8000, 8080, 8001, 8002, 8003],
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct BackendProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub probed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derived_serving: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BackendConfig {
#[serde(default)]
pub name: String,
#[serde(default)]
pub endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_path: Option<String>,
#[serde(default)]
pub tiers: Vec<Tier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<BackendKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<OpenAiApi>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serving: Option<Serving>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coexist: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ram_gib: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub card: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<crate::model_card::Capability>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<BackendProvenance>,
}
impl BackendConfig {
pub fn effective_model(&self) -> Option<&str> {
self.model.as_deref().filter(|m| !m.trim().is_empty())
}
pub fn needs_kind_probe(&self) -> bool {
self.kind.is_none()
}
pub fn kind_label(&self) -> &'static str {
self.kind.map(BackendKind::label).unwrap_or("auto")
}
pub fn resolve_api_key(&self) -> Option<String> {
if let Some(var) = &self.api_key_env {
if let Ok(val) = std::env::var(var) {
let val = val.trim();
if !val.is_empty() {
return Some(val.to_string());
}
}
}
if let Some(path) = &self.api_key_file {
let expanded = expand_tilde(path);
if let Ok(contents) = std::fs::read_to_string(&expanded) {
if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
return Some(token.to_string());
}
}
}
None
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BackendOverride {
pub name: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub model_path: Option<String>,
pub tiers: Option<Vec<Tier>>,
pub kind: Option<BackendKind>,
pub api: Option<OpenAiApi>,
pub api_key_env: Option<String>,
pub api_key_file: Option<String>,
pub serving: Option<Serving>,
pub host: Option<String>,
pub coexist: Option<bool>,
pub ram_gib: Option<f64>,
pub card: Option<String>,
}
impl BackendOverride {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
pub fn apply(&self, cfg: &mut Config) {
if self.is_empty() {
return;
}
let name = self.name.clone().unwrap_or_else(|| "cli".to_string());
let has_destination = self.endpoint.is_some() || self.model_path.is_some();
if has_destination {
let mut backend = cfg
.backends
.iter()
.find(|b| b.name == name)
.cloned()
.unwrap_or_else(|| BackendConfig {
name: name.clone(),
..Default::default()
});
backend.name = name;
self.overlay(&mut backend);
if backend.tiers.is_empty() {
backend.tiers = vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review];
}
cfg.backends = vec![backend];
return;
}
let idx = match self.name.as_deref() {
Some(n) => cfg.backends.iter().position(|b| b.name == n),
None => (!cfg.backends.is_empty()).then_some(0),
};
if let Some(i) = idx {
self.overlay(&mut cfg.backends[i]);
}
}
fn overlay(&self, backend: &mut BackendConfig) {
if let Some(v) = &self.endpoint {
backend.endpoint = v.clone();
}
if let Some(v) = &self.model {
backend.model = Some(v.clone());
}
if let Some(v) = &self.model_path {
backend.model_path = Some(v.clone());
}
if let Some(v) = &self.tiers {
backend.tiers = v.clone();
}
if let Some(v) = self.kind {
backend.kind = Some(v);
}
if let Some(v) = self.api {
backend.api = Some(v);
}
if let Some(v) = &self.api_key_env {
backend.api_key_env = Some(v.clone());
}
if let Some(v) = &self.api_key_file {
backend.api_key_file = Some(v.clone());
}
if let Some(v) = self.serving {
backend.serving = Some(v);
}
if let Some(v) = &self.host {
backend.host = Some(v.clone());
}
if let Some(v) = self.coexist {
backend.coexist = Some(v);
}
if let Some(v) = self.ram_gib {
backend.ram_gib = Some(v);
}
if let Some(v) = &self.card {
backend.card = Some(v.clone());
}
}
}
static CLI_BACKEND_OVERRIDE: std::sync::Mutex<Option<BackendOverride>> =
std::sync::Mutex::new(None);
pub fn set_cli_backend_override(over: BackendOverride) {
if let Ok(mut slot) = CLI_BACKEND_OVERRIDE.lock() {
*slot = Some(over);
}
}
fn cli_backend_override() -> Option<BackendOverride> {
CLI_BACKEND_OVERRIDE.lock().ok().and_then(|s| s.clone())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SummarizerConfig {
pub endpoint: Option<String>,
pub model: Option<String>,
pub kind: Option<BackendKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_path: Option<String>,
pub api_key_file: Option<String>,
pub api_key_env: Option<String>,
#[serde(default = "default_summarizer_timeout_secs")]
pub timeout_secs: u64,
#[serde(default = "default_summarizer_retries")]
pub retries: u32,
pub fallback_model: Option<String>,
pub keep_alive: Option<String>,
}
impl Default for SummarizerConfig {
fn default() -> Self {
Self {
endpoint: None,
model: None,
kind: None,
model_path: None,
api_key_file: None,
api_key_env: None,
timeout_secs: default_summarizer_timeout_secs(),
retries: default_summarizer_retries(),
fallback_model: None,
keep_alive: None,
}
}
}
impl SummarizerConfig {
pub fn from_toml_str(text: &str) -> Result<Self> {
toml::from_str(text).map_err(|e| NewtError::Config(e.to_string()))
}
pub fn resolve() -> Result<Self> {
for path in Self::candidate_paths() {
if path.is_file() {
let text = std::fs::read_to_string(&path)?;
return Self::from_toml_str(&text);
}
}
Ok(Self::default())
}
fn candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(p) = std::env::var("NEWT_SUMMARIZER_CONFIG") {
paths.push(PathBuf::from(p));
}
if let Some(dir) = Config::user_config_dir() {
paths.push(dir.join("summarizer.toml"));
}
paths
}
pub fn resolve_api_key(&self) -> Option<String> {
if let Some(var) = &self.api_key_env {
if let Ok(val) = std::env::var(var) {
let val = val.trim();
if !val.is_empty() {
return Some(val.to_string());
}
}
}
if let Some(path) = &self.api_key_file {
let expanded = expand_tilde(path);
if let Ok(contents) = std::fs::read_to_string(&expanded) {
if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
return Some(token.to_string());
}
}
}
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub name: String,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub env_pass: Vec<String>,
pub tiers: Vec<Tier>,
}
pub fn write_backend_dropin(
config_path: &std::path::Path,
backend: &BackendConfig,
) -> std::result::Result<std::path::PathBuf, String> {
if backend.name.trim().is_empty() {
return Err("backend drop-in needs a name (it becomes the filename)".into());
}
let dir = config_path.with_file_name("backends");
std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
let path = dir.join(format!("{}.toml", backend.name));
let body = toml::to_string(backend).map_err(|e| format!("serialize backend: {e}"))?;
std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path)
}
pub fn writeback_probed_backend(
patch: &BackendConfig,
) -> std::result::Result<Option<std::path::PathBuf>, String> {
if patch.name.trim().is_empty() {
return Ok(None);
}
let Some(config_path) = Config::user_config_path() else {
return Ok(None);
};
let dir = config_path.with_file_name("backends");
let path = dir.join(format!("{}.toml", patch.name));
let mut merged = if path.is_file() {
let text =
std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
toml::from_str::<BackendConfig>(&text)
.map_err(|e| format!("parse {}: {e}", path.display()))?
} else {
BackendConfig {
name: patch.name.clone(),
endpoint: patch.endpoint.clone(),
..Default::default()
}
};
merged.name = patch.name.clone();
if !patch.endpoint.is_empty() {
merged.endpoint = patch.endpoint.clone();
}
if patch.kind.is_some() {
merged.kind = patch.kind;
}
if patch.api.is_some() {
merged.api = patch.api;
}
if patch.model.is_some() {
merged.model = patch.model.clone();
}
if patch.serving.is_some() {
merged.serving = patch.serving;
}
if patch.api_key_env.is_some() {
merged.api_key_env = patch.api_key_env.clone();
}
if patch.api_key_file.is_some() {
merged.api_key_file = patch.api_key_file.clone();
}
merged.provenance = Some(BackendProvenance {
source: Some(format!(
"newt adopt v{} (probed; delete this file to reset)",
env!("CARGO_PKG_VERSION")
)),
probed: Some(chrono::Local::now().format("%Y-%m-%d").to_string()),
derived_serving: patch
.serving
.map(|_| true)
.or_else(|| merged.provenance.as_ref().and_then(|p| p.derived_serving)),
});
write_backend_dropin(&config_path, &merged).map(Some)
}
fn fallback_localhost_backend() -> BackendConfig {
BackendConfig {
name: "ollama".into(),
endpoint: "http://127.0.0.1:11434".into(),
model: Some("llama3.1:8b".into()),
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
kind: Some(BackendKind::Ollama),
..Default::default()
}
}
impl Default for Config {
fn default() -> Self {
Self {
backends: vec![fallback_localhost_backend()],
default_backend: None,
discovery: Discovery::default(),
providers: Vec::new(),
scratch: None,
default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
lifecycle: None,
dgx: None,
tui: None,
shell: None,
intake: None,
context: None,
tools: None,
tenacity: None,
tool_exposure: None,
pricing: None,
memory: None,
agents: AgentsConfig::default(),
mcp_servers: Vec::new(),
logs: None,
skills: None,
model_tuning: Vec::new(),
conversations: None,
merge: None,
permission_presets: std::collections::BTreeMap::new(),
modes: std::collections::BTreeMap::new(),
profiles: std::collections::BTreeMap::new(),
bundles: std::collections::BTreeMap::new(),
loadouts: std::collections::BTreeMap::new(),
crews: std::collections::BTreeMap::new(),
crew: None,
plan: 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 base_path = Self::candidate_paths().into_iter().find(|p| p.is_file());
let base_ambient = base_is_ambient_newt_toml(base_path.as_deref());
let project_path =
Self::project_config_path().filter(|p| Some(p.as_path()) != base_path.as_deref());
let mut cfg = match (&base_path, &project_path) {
(Some(p), None) => Self::load(p)?,
(None, None) => Self::default(),
(base, Some(proj)) => {
let mut merged = match base {
Some(p) => Self::load_value(p)?,
None => toml::Value::try_from(Self::default())
.map_err(|e| NewtError::Config(e.to_string()))?,
};
let project_val = Self::load_value(proj)?;
let strategy = array_merge_strategy(&project_val, &merged);
let project_mcp_count = project_val
.get("mcp_servers")
.and_then(toml::Value::as_array)
.map(Vec::len);
merge_toml(&mut merged, project_val, strategy);
let mut cfg: Self = merged
.try_into()
.map_err(|e| NewtError::Config(e.to_string()))?;
mark_project_mcp_untrusted(&mut cfg.mcp_servers, strategy, project_mcp_count);
cfg
}
};
if base_ambient {
for entry in &mut cfg.mcp_servers {
entry.trust = crate::mcp::McpTrust::Untrusted;
}
}
cfg.merge_disk_backends();
if cfg.backends.is_empty() {
cfg.backends.push(fallback_localhost_backend());
}
if let Some(over) = cli_backend_override() {
over.apply(&mut cfg);
}
cfg.merge_disk_bundles();
cfg.merge_disk_loadouts();
cfg.merge_disk_crews();
cfg.merge_disk_dgx_nodes();
crate::agentic::set_max_output_tokens(cfg.max_output_tokens());
crate::agentic::set_output_head_tokens(cfg.output_head_tokens());
crate::agentic::set_output_cap_chars_per_token(cfg.output_cap_chars_per_token());
crate::tenacity::set_tenacity_config(cfg.tenacity.clone().unwrap_or_default());
if let Some(lc) = &cfg.lifecycle {
crate::tooling::set_lifecycle_override(lc.clone());
}
if let Some(dir) = cfg.scratch.as_ref().and_then(|s| s.dir.as_deref()) {
crate::scratch::set_scratch_dir(dir);
}
Ok(cfg)
}
pub fn max_output_tokens(&self) -> usize {
self.tools
.as_ref()
.map(|t| t.max_output_tokens)
.unwrap_or_else(default_max_output_tokens)
}
pub fn tool_exposure(&self) -> ToolExposureConfig {
self.tool_exposure.unwrap_or_default()
}
pub fn output_head_tokens(&self) -> usize {
self.tools
.as_ref()
.map(|t| t.output_head_tokens)
.unwrap_or_else(default_output_head_tokens)
}
pub fn output_cap_chars_per_token(&self) -> usize {
self.tools
.as_ref()
.map(|t| t.output_cap_chars_per_token)
.unwrap_or_else(default_output_cap_chars_per_token)
}
fn merge_disk_backends(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_backends_from_dir(&dir.join("backends"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_backends_from_dir(&parent.join("backends"));
}
}
}
fn merge_backends_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<BackendConfig>(&t)) {
Ok(Ok(mut backend)) => {
if backend.endpoint.is_empty() && backend.model_path.is_none() {
tracing::warn!(
path = %path.display(),
"skipping backend with neither endpoint nor model_path"
);
continue;
}
backend.name = stem.to_string();
match self.backends.iter_mut().find(|b| b.name == backend.name) {
Some(existing) => {
if backend.api_key_env.is_none() {
backend.api_key_env = existing.api_key_env.clone();
}
if backend.api_key_file.is_none() {
backend.api_key_file = existing.api_key_file.clone();
}
if backend.tiers.is_empty() {
backend.tiers = existing.tiers.clone();
}
*existing = backend;
}
None => self.backends.push(backend),
}
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed backend file");
}
Err(_) => {}
}
}
}
fn merge_disk_dgx_nodes(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_dgx_nodes_from_dir(&dir.join("dgx"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_dgx_nodes_from_dir(&parent.join("dgx"));
}
}
}
fn merge_dgx_nodes_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::dgx::DgxNode>(&t))
{
Ok(Ok(mut node)) => {
node.name = stem.to_string();
let dgx = self.dgx.get_or_insert_with(Default::default);
match dgx.nodes.iter_mut().find(|n| n.name == node.name) {
Some(existing) => *existing = node,
None => dgx.nodes.push(node),
}
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed dgx node file");
}
Err(_) => {}
}
}
}
fn merge_disk_crews(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_crews_from_dir(&dir.join("crews"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_crews_from_dir(&parent.join("crews"));
}
}
}
fn merge_crews_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<Crew>(&t)) {
Ok(Ok(crew)) => {
self.crews.insert(stem.to_string(), crew);
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed crew file");
}
Err(_) => {}
}
}
}
fn merge_disk_bundles(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_bundles_from_dir(&dir.join("bundles"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_bundles_from_dir(&parent.join("bundles"));
}
}
}
fn merge_bundles_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<BundleConfig>(&t)) {
Ok(Ok(bundle)) => {
self.bundles.insert(stem.to_string(), bundle);
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed bundle file");
}
Err(_) => {}
}
}
}
fn merge_disk_loadouts(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_loadouts_from_dir(&dir.join("loadouts"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_loadouts_from_dir(&parent.join("loadouts"));
}
}
}
fn merge_loadouts_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<Loadout>(&t)) {
Ok(Ok(loadout)) => {
self.loadouts.insert(stem.to_string(), loadout);
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed loadout file");
}
Err(_) => {}
}
}
}
fn load_value(path: &Path) -> Result<toml::Value> {
let text = std::fs::read_to_string(path)?;
toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
}
pub fn project_config_path() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
find_project_config_from(&cwd, home_dir().as_deref())
}
pub fn user_config_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os(NEWT_CONFIG_DIR_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
{
return Some(path);
}
home_dir().map(|h| h.join(".newt"))
}
pub fn user_config_path() -> Option<PathBuf> {
Self::user_config_dir().map(|dir| dir.join("config.toml"))
}
pub fn to_redacted_toml(&self) -> Result<String> {
use crate::mcp::SecretValue;
let mut redacted = self.clone();
let redact = |v: &mut SecretValue| {
if matches!(v, SecretValue::Literal(_)) {
*v = SecretValue::Literal(Self::REDACTED.to_string());
}
};
for server in &mut redacted.mcp_servers {
server.env.values_mut().for_each(redact);
server.headers.values_mut().for_each(redact);
if let Some(url) = &server.url {
server.url = Some(redact_url_secrets(url));
}
server.args = redact_arg_secrets(&server.args);
}
toml::to_string_pretty(&redacted).map_err(|e| NewtError::Config(e.to_string()))
}
pub const REDACTED: &'static str = "<redacted>";
#[must_use]
pub fn skill_search_dirs(&self) -> Vec<PathBuf> {
let configured = self
.skills
.as_ref()
.map(|s| s.search.as_slice())
.unwrap_or(&[]);
let mut dirs: Vec<PathBuf> = if configured.is_empty() {
let default = Self::user_config_dir()
.map(|dir| dir.join("skills"))
.unwrap_or_else(|| PathBuf::from(".newt/skills"));
vec![default]
} else {
configured.iter().map(|s| expand_tilde(s)).collect()
};
if let Some(bundled) = self
.skills
.as_ref()
.map(|s| s.bundled_dir.as_str())
.filter(|s| !s.is_empty())
{
dirs.push(expand_tilde(bundled));
}
dirs
}
#[must_use]
pub fn with_bundled_default(mut self) -> Self {
let already_set = self
.skills
.as_ref()
.is_some_and(|s| !s.bundled_dir.is_empty());
if already_set {
return self;
}
let Ok(cwd) = std::env::current_dir() else {
return self;
};
if let Some(dir) =
find_ancestor_dir(&cwd, Path::new(".newt/bundled-skills"), |p| p.is_dir())
{
self.skills
.get_or_insert_with(SkillsConfig::default)
.bundled_dir = dir.to_string_lossy().into_owned();
}
self
}
#[must_use]
pub fn personas_dir() -> PathBuf {
Self::user_config_path()
.map(|p| p.with_file_name("personas"))
.unwrap_or_else(|| PathBuf::from("personas"))
}
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)
}
#[must_use]
pub fn mcp_probe_caveats(&self, workspace: &Path) -> crate::caveats::Caveats {
let ws = workspace.to_string_lossy();
self.tui
.as_ref()
.map(|t| t.permissions.to_caveats(&ws))
.unwrap_or_else(|| {
ToolPermissions {
preset: PermissionPreset::ReadOnly,
extra_exec: Vec::new(),
net: Vec::new(),
prompt: false,
}
.to_caveats(&ws)
})
}
pub fn with_default_backend(text: &str, name: &str) -> Result<String> {
if name.trim().is_empty() {
return Err(NewtError::Config(
"default backend name cannot be empty".to_string(),
));
}
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let root = doc.as_table_mut();
if let Some(item) = root.get_mut("default_backend") {
let existing = item.as_str().ok_or_else(|| {
NewtError::Config("`default_backend` is not a string".to_string())
})?;
if existing == name {
return Ok(doc.to_string());
}
let decor = item
.as_value()
.expect("a string item is always a value")
.decor()
.clone();
*item = toml_edit::value(name);
*item
.as_value_mut()
.expect("toml_edit::value always creates a value")
.decor_mut() = decor;
} else {
root.insert("default_backend", toml_edit::value(name));
}
Ok(doc.to_string())
}
pub fn with_mcp_enabled(text: &str, name: &str, enabled: bool) -> Result<String> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let servers =
doc.as_table_mut()
.entry("mcp_servers")
.or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
));
let arr = servers.as_array_of_tables_mut().ok_or_else(|| {
NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
})?;
let entry = arr
.iter_mut()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name))
.ok_or_else(|| NewtError::Config(format!("no [[mcp_servers]] entry named `{name}`")))?;
if enabled {
entry.remove("enabled");
} else {
entry["enabled"] = toml_edit::value(false);
}
Ok(doc.to_string())
}
pub fn with_mcp_server_added(text: &str, entry: &crate::mcp::McpServerEntry) -> Result<String> {
crate::mcp::validate_entry_for_write(entry)?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let servers =
doc.as_table_mut()
.entry("mcp_servers")
.or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
));
let arr = servers.as_array_of_tables_mut().ok_or_else(|| {
NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
})?;
if arr
.iter()
.any(|t| t.get("name").and_then(|v| v.as_str()) == Some(entry.name.as_str()))
{
return Err(NewtError::Config(format!(
"an [[mcp_servers]] entry named `{}` already exists (remove it first, \
or toggle it with `/mcp enable|disable`)",
entry.name
)));
}
arr.push(crate::mcp::entry_to_toml_table(entry, None)?);
Ok(doc.to_string())
}
pub fn with_mcp_server_removed(text: &str, name: &str) -> Result<String> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let item = doc
.as_table_mut()
.get_mut("mcp_servers")
.ok_or_else(|| NewtError::Config(format!("no [[mcp_servers]] entry named `{name}`")))?;
let arr = item.as_array_of_tables_mut().ok_or_else(|| {
NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
})?;
let before = arr.len();
arr.retain(|t| t.get("name").and_then(|v| v.as_str()) != Some(name));
if arr.len() == before {
return Err(NewtError::Config(format!(
"no [[mcp_servers]] entry named `{name}`"
)));
}
Ok(doc.to_string())
}
pub fn with_net_host(text: &str, host: &str) -> Result<String> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let tui = doc
.as_table_mut()
.entry("tui")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
let tui_tbl = tui
.as_table_mut()
.ok_or_else(|| NewtError::Config("[tui] is not a table".to_string()))?;
let perms = tui_tbl
.entry("permissions")
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
let perms_tbl = perms
.as_table_mut()
.ok_or_else(|| NewtError::Config("[tui.permissions] is not a table".to_string()))?;
let net =
perms_tbl
.entry("net")
.or_insert(toml_edit::Item::Value(toml_edit::Value::Array(
toml_edit::Array::new(),
)));
let arr = net.as_array_mut().ok_or_else(|| {
NewtError::Config("[tui.permissions] net is not an array".to_string())
})?;
if !arr.iter().any(|v| v.as_str() == Some(host)) {
arr.push(host);
}
Ok(doc.to_string())
}
pub fn append_permission_net_host(path: &Path, host: &str) -> Result<()> {
let text = std::fs::read_to_string(path).unwrap_or_default();
let updated = Self::with_net_host(&text, host)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
}
std::fs::write(path, updated).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(path) = Self::user_config_path() {
paths.push(path);
}
paths.push(PathBuf::from("/etc/newt/config.toml"));
paths
}
}
const SENSITIVE_QUERY_KEYS: &[&str] = &[
"api_key",
"apikey",
"token",
"access_token",
"secret",
"password",
"key",
];
const SENSITIVE_ARG_FLAGS: &[&str] = &["--token", "--api-key", "--password", "--secret", "--key"];
fn redact_url_secrets(url: &str) -> String {
let (main, fragment) = match url.split_once('#') {
Some((m, f)) => (m, Some(f)),
None => (url, None),
};
let (authority_and_path, query) = match main.split_once('?') {
Some((b, q)) => (b, Some(q)),
None => (main, None),
};
let mut out = redact_url_userinfo(authority_and_path);
if let Some(q) = query {
out.push('?');
out.push_str(&redact_url_query(q));
}
if let Some(f) = fragment {
out.push('#');
out.push_str(f);
}
out
}
fn redact_url_userinfo(s: &str) -> String {
let Some((scheme, rest)) = s.split_once("://") else {
return s.to_string();
};
let (authority, path) = match rest.split_once('/') {
Some((a, p)) => (a, Some(p)),
None => (rest, None),
};
let authority = match authority.rsplit_once('@') {
Some((_userinfo, host)) => format!("{}@{host}", Config::REDACTED),
None => authority.to_string(),
};
let mut out = format!("{scheme}://{authority}");
if let Some(p) = path {
out.push('/');
out.push_str(p);
}
out
}
fn redact_url_query(query: &str) -> String {
query
.split('&')
.map(|param| match param.split_once('=') {
Some((k, _))
if SENSITIVE_QUERY_KEYS
.iter()
.any(|s| s.eq_ignore_ascii_case(k)) =>
{
format!("{k}={}", Config::REDACTED)
}
_ => param.to_string(),
})
.collect::<Vec<_>>()
.join("&")
}
fn is_sensitive_arg_flag(flag: &str) -> bool {
SENSITIVE_ARG_FLAGS
.iter()
.any(|s| s.eq_ignore_ascii_case(flag))
}
fn redact_arg_secrets(args: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(args.len());
let mut redact_next = false;
for arg in args {
if redact_next {
out.push(Config::REDACTED.to_string());
redact_next = false;
continue;
}
match arg.split_once('=') {
Some((flag, _)) if is_sensitive_arg_flag(flag) => {
out.push(format!("{flag}={}", Config::REDACTED));
}
_ if is_sensitive_arg_flag(arg) => {
out.push(arg.clone());
redact_next = true;
}
_ => out.push(arg.clone()),
}
}
out
}
pub(crate) fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub(crate) fn merge_toml(base: &mut toml::Value, overlay: toml::Value, arrays: ArrayMergeStrategy) {
match (base, overlay) {
(toml::Value::Table(base_tbl), toml::Value::Table(overlay_tbl)) => {
for (key, val) in overlay_tbl {
match base_tbl.get_mut(&key) {
Some(existing) => merge_toml(existing, val, arrays),
None => {
base_tbl.insert(key, val);
}
}
}
}
(toml::Value::Array(base_arr), toml::Value::Array(overlay_arr))
if arrays == ArrayMergeStrategy::Append =>
{
base_arr.extend(overlay_arr);
}
(slot, overlay) => *slot = overlay,
}
}
fn mark_project_mcp_untrusted(
servers: &mut [crate::mcp::McpServerEntry],
strategy: ArrayMergeStrategy,
project_mcp_count: Option<usize>,
) {
let Some(count) = project_mcp_count else {
return;
};
let start = match strategy {
ArrayMergeStrategy::Replace => 0,
ArrayMergeStrategy::Append => servers.len().saturating_sub(count),
};
for entry in &mut servers[start..] {
entry.trust = crate::mcp::McpTrust::Untrusted;
}
}
fn base_is_ambient_newt_toml(base: Option<&Path>) -> bool {
let ambient_candidate = Path::new("./newt.toml");
if base != Some(ambient_candidate) {
return false;
}
match std::env::var("NEWT_CONFIG") {
Ok(explicit) => Path::new(&explicit) != ambient_candidate,
Err(_) => true,
}
}
fn array_merge_strategy(project: &toml::Value, base: &toml::Value) -> ArrayMergeStrategy {
read_array_strategy(project)
.or_else(|| read_array_strategy(base))
.unwrap_or_default()
}
fn read_array_strategy(value: &toml::Value) -> Option<ArrayMergeStrategy> {
match value.get("merge")?.get("arrays")?.as_str()? {
"append" => Some(ArrayMergeStrategy::Append),
"replace" => Some(ArrayMergeStrategy::Replace),
_ => None,
}
}
pub(crate) fn find_project_config_from(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(current) = dir {
if home == Some(current) {
break;
}
let candidate = current.join(".newt").join("config.toml");
if candidate.is_file() {
return Some(candidate);
}
dir = current.parent();
}
None
}
pub(crate) fn expand_tilde(path: &str) -> PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = home_dir() {
return home.join(rest);
}
} else if path == "~" {
if let Some(home) = home_dir() {
return home;
}
}
PathBuf::from(path)
}
pub(crate) fn find_ancestor_dir(
start: &Path,
rel: &Path,
exists: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
for ancestor in start.ancestors() {
let candidate = ancestor.join(rel);
if exists(&candidate) {
return Some(candidate);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::caveats::CaveatsExt;
use std::io::Write;
#[test]
fn footer_mode_defaults_to_auto_and_round_trips() {
let cfg: TuiConfig = toml::from_str("").unwrap();
assert_eq!(cfg.footer, FooterMode::Auto);
for (key, want) in [
("auto", FooterMode::Auto),
("on", FooterMode::On),
("off", FooterMode::Off),
] {
let cfg: TuiConfig = toml::from_str(&format!("footer = \"{key}\"")).unwrap();
assert_eq!(cfg.footer, want, "footer = {key}");
}
}
#[test]
fn color_mode_defaults_to_auto_and_round_trips() {
let cfg: TuiConfig = toml::from_str("").unwrap();
assert_eq!(cfg.color, ColorMode::Auto);
for (key, want) in [
("auto", ColorMode::Auto),
("always", ColorMode::Always),
("never", ColorMode::Never),
("minimal", ColorMode::Minimal),
("inverted", ColorMode::Inverted),
("dark", ColorMode::Dark),
("light", ColorMode::Light),
("mono", ColorMode::Mono),
] {
let cfg: TuiConfig = toml::from_str(&format!("color = \"{key}\"")).unwrap();
assert_eq!(cfg.color, want, "color = {key}");
}
}
#[test]
fn color_mode_keyword_round_trips_and_aliases_parse() {
for m in [
ColorMode::Auto,
ColorMode::Always,
ColorMode::Never,
ColorMode::Minimal,
ColorMode::Inverted,
ColorMode::Dark,
ColorMode::Light,
ColorMode::Mono,
] {
assert_eq!(ColorMode::from_keyword(m.keyword()), Some(m));
}
assert_eq!(ColorMode::from_keyword("ALWAYS"), Some(ColorMode::Always));
assert_eq!(ColorMode::from_keyword(" on "), Some(ColorMode::Always));
assert_eq!(ColorMode::from_keyword("off"), Some(ColorMode::Never));
assert_eq!(ColorMode::from_keyword("monochrome"), Some(ColorMode::Mono));
assert_eq!(ColorMode::from_keyword("rainbow"), None);
}
#[test]
fn color_mode_forced_and_is_mono() {
assert_eq!(ColorMode::Always.forced(), Some(true));
assert_eq!(ColorMode::Dark.forced(), Some(true));
assert_eq!(ColorMode::Light.forced(), Some(true));
assert_eq!(ColorMode::Inverted.forced(), Some(true));
assert_eq!(ColorMode::Minimal.forced(), Some(true));
assert_eq!(ColorMode::Never.forced(), Some(false));
assert_eq!(ColorMode::Mono.forced(), Some(false));
assert_eq!(ColorMode::Auto.forced(), None);
assert!(ColorMode::Mono.is_mono());
assert!(!ColorMode::Never.is_mono());
assert!(!ColorMode::Auto.is_mono());
}
#[test]
fn markdown_mode_defaults_to_auto_round_trips_and_forces() {
assert_eq!(MarkdownMode::default(), MarkdownMode::Auto);
for m in [MarkdownMode::Auto, MarkdownMode::On, MarkdownMode::Off] {
assert_eq!(MarkdownMode::from_keyword(m.keyword()), Some(m));
}
assert_eq!(MarkdownMode::from_keyword("ON"), Some(MarkdownMode::On));
assert_eq!(
MarkdownMode::from_keyword(" always "),
Some(MarkdownMode::On)
);
assert_eq!(MarkdownMode::from_keyword("never"), Some(MarkdownMode::Off));
assert_eq!(MarkdownMode::from_keyword("rainbow"), None);
assert_eq!(MarkdownMode::On.forced(), Some(true));
assert_eq!(MarkdownMode::Off.forced(), Some(false));
assert_eq!(MarkdownMode::Auto.forced(), None);
}
#[test]
fn tui_markdown_parses_from_toml_and_defaults_to_auto() {
let cfg: TuiConfig = toml::from_str("markdown = \"off\"").unwrap();
assert_eq!(cfg.markdown, MarkdownMode::Off);
let default: TuiConfig = toml::from_str("").unwrap();
assert_eq!(default.markdown, MarkdownMode::Auto);
}
#[test]
fn backend_kind_embedded_parses_and_labels() {
#[derive(serde::Deserialize)]
struct K {
kind: BackendKind,
}
let k: K = toml::from_str("kind = \"embedded\"").unwrap();
assert_eq!(k.kind, BackendKind::Embedded);
assert_eq!(k.kind.label(), "embedded");
}
#[test]
fn summarizer_config_defaults_and_parse() {
let d = SummarizerConfig::default();
assert_eq!(d.endpoint, None);
assert_eq!(d.model, None);
assert_eq!(d.kind, None);
assert_eq!(d.timeout_secs, 60);
assert_eq!(d.retries, 1);
assert_eq!(d.fallback_model, None);
let cfg = SummarizerConfig::from_toml_str(
"endpoint = \"http://REDACTED-HOST:11434\"\n\
model = \"qwen2.5-coder:3b\"\n\
kind = \"openai\"\n\
timeout_secs = 45\n\
retries = 2\n\
fallback_model = \"nemotron-mini:4b\"\n\
keep_alive = \"10m\"",
)
.unwrap();
assert_eq!(cfg.endpoint.as_deref(), Some("http://REDACTED-HOST:11434"));
assert_eq!(cfg.model.as_deref(), Some("qwen2.5-coder:3b"));
assert_eq!(cfg.kind, Some(BackendKind::Openai));
assert_eq!(cfg.timeout_secs, 45);
assert_eq!(cfg.retries, 2);
assert_eq!(cfg.fallback_model.as_deref(), Some("nemotron-mini:4b"));
assert_eq!(cfg.keep_alive.as_deref(), Some("10m"));
}
#[test]
fn summarizer_config_partial_keeps_defaults() {
let cfg = SummarizerConfig::from_toml_str("endpoint = \"http://fast.box:11434\"").unwrap();
assert_eq!(cfg.endpoint.as_deref(), Some("http://fast.box:11434"));
assert_eq!(cfg.model, None); assert_eq!(cfg.timeout_secs, 60); assert_eq!(cfg.retries, 1); }
#[test]
fn context_manager_keyword_roundtrip_and_availability() {
for m in [
ContextManager::Standard,
ContextManager::Progressive,
ContextManager::Distributed,
] {
assert_eq!(ContextManager::from_keyword(m.keyword()), Some(m));
}
assert_eq!(
ContextManager::from_keyword(" STANDARD "),
Some(ContextManager::Standard),
"case/space-insensitive"
);
assert_eq!(ContextManager::from_keyword("nope"), None);
assert!(ContextManager::Standard.available());
assert!(!ContextManager::Progressive.available());
assert!(!ContextManager::Distributed.available());
assert_eq!(ContextManager::default(), ContextManager::Standard);
}
#[test]
fn compaction_trigger_policy_keyword_roundtrip_and_default() {
for policy in [
CompactionTriggerPolicy::HeadroomAware,
CompactionTriggerPolicy::MessageCount,
] {
assert_eq!(
CompactionTriggerPolicy::from_keyword(policy.as_str()),
Some(policy)
);
assert_eq!(policy.keyword(), policy.as_str());
}
assert_eq!(
CompactionTriggerPolicy::from_keyword(" MESSAGE_COUNT "),
Some(CompactionTriggerPolicy::MessageCount),
"case/space-insensitive"
);
assert_eq!(CompactionTriggerPolicy::from_keyword("nope"), None);
assert_eq!(
CompactionTriggerPolicy::default(),
CompactionTriggerPolicy::HeadroomAware
);
}
#[test]
fn context_section_defaults_and_parses() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.context.is_none());
let c: ContextConfig = toml::from_str(
"manager = \"progressive\"\ncompaction_trigger_policy = \"message_count\"",
)
.unwrap();
assert_eq!(c.manager, ContextManager::Progressive);
assert_eq!(
c.compaction_trigger_policy,
CompactionTriggerPolicy::MessageCount
);
let defaults = ContextConfig::default();
assert_eq!(defaults.manager, ContextManager::Standard);
assert_eq!(
defaults.compaction_trigger_policy,
CompactionTriggerPolicy::HeadroomAware
);
let parsed_default: ContextConfig = toml::from_str("manager = \"standard\"").unwrap();
assert_eq!(
parsed_default.compaction_trigger_policy,
CompactionTriggerPolicy::HeadroomAware
);
assert!(
toml::from_str::<ContextConfig>("compaction_trigger_policy = \"not_a_policy\"")
.is_err(),
"an invalid policy must fail config parsing rather than silently changing safety behavior"
);
}
#[test]
fn scratch_section_defaults_and_parses() {
let bare: Config = toml::from_str("").unwrap();
assert!(bare.scratch.is_none());
let cfg: Config = toml::from_str("[scratch]\ndir = \"/tmp/newt-scratch\"\n").unwrap();
assert_eq!(
cfg.scratch.and_then(|s| s.dir).as_deref(),
Some("/tmp/newt-scratch")
);
}
#[test]
fn semantic_config_defaults_and_parses() {
let d = SemanticConfig::default();
assert_eq!(d.embedding_model, "nomic-embed-text");
assert_eq!(d.top_k, 5);
assert_eq!(d.embeddings_endpoint, None);
assert_eq!(d.embeddings_api, None);
assert_eq!(d.on_embed_failure, OnEmbedFailure::Disable);
assert_eq!(d.embedding_model_path, None);
let c: ContextConfig = toml::from_str(
"[semantic]\nembedding_model = \"mxbai-embed-large\"\ntop_k = 8\n\
embedding_model_path = \"/models/bge-small-en-v1.5\"\n\
embeddings_endpoint = \"http://REDACTED-HOST:11434\"\n\
embeddings_api = \"ollama\"\non_embed_failure = \"warn\"",
)
.unwrap();
assert_eq!(c.semantic.embedding_model, "mxbai-embed-large");
assert_eq!(
c.semantic.embedding_model_path.as_deref(),
Some("/models/bge-small-en-v1.5")
);
assert_eq!(c.semantic.top_k, 8);
assert_eq!(
c.semantic.embeddings_endpoint.as_deref(),
Some("http://REDACTED-HOST:11434")
);
assert_eq!(c.semantic.embeddings_api, Some(BackendKind::Ollama));
assert_eq!(c.semantic.on_embed_failure, OnEmbedFailure::Warn);
let v: ContextConfig = toml::from_str("[semantic]\nembeddings_api = \"vllm\"").unwrap();
assert_eq!(v.semantic.embeddings_api, Some(BackendKind::Openai));
let bare: ContextConfig = toml::from_str("manager = \"standard\"").unwrap();
assert_eq!(bare.semantic, SemanticConfig::default());
}
#[test]
fn context_feature_keyword_alias_availability_and_issue() {
for f in ContextFeature::ALL {
assert_eq!(ContextFeature::from_keyword(f.keyword()), Some(f));
}
assert_eq!(
ContextFeature::from_keyword("TOOL-OFFLOAD"),
Some(ContextFeature::ToolOffload)
);
assert_eq!(
ContextFeature::from_keyword("offload"),
Some(ContextFeature::ToolOffload)
);
assert_eq!(
ContextFeature::from_keyword(" state "),
Some(ContextFeature::Scratchpad)
);
assert_eq!(ContextFeature::from_keyword("nope"), None);
assert!(ContextFeature::ToolOffload.available());
assert!(ContextFeature::Scratchpad.available());
assert!(ContextFeature::Semantic.available());
assert!(ContextFeature::Experiential.available());
assert!(ContextFeature::Scheduled.available());
assert!(
!ContextFeature::Provenance.available(),
"provenance still pending"
);
assert!(ContextFeature::ALL
.iter()
.filter(|f| !matches!(f, ContextFeature::Provenance))
.all(|f| f.available()));
assert_eq!(ContextFeature::Semantic.issue(), 582);
assert_eq!(ContextFeature::Scratchpad.issue(), 583);
assert_eq!(ContextFeature::ToolOffload.issue(), 584);
assert_eq!(ContextFeature::Provenance.issue(), 584);
assert_eq!(ContextFeature::Experiential.issue(), 585);
assert_eq!(ContextFeature::Scheduled.issue(), 586);
}
#[test]
fn context_features_override_layering_and_parse() {
use ContextFeature as F;
let base = ContextManager::Standard.base_features();
assert!(base.enabled().is_empty());
let mut ov = ContextFeatures::default();
ov.set(F::Scratchpad, Some(true));
let resolved = ov.apply_to(base);
assert!(resolved.get(F::Scratchpad));
assert!(!resolved.get(F::Semantic));
assert_eq!(resolved.enabled(), vec![F::Scratchpad]);
let mut ov2 = ContextFeatures::default();
ov2.set(F::Scratchpad, Some(false));
assert!(!ov2.apply_to(resolved).get(F::Scratchpad));
let c: ContextConfig = toml::from_str(
"manager = \"standard\"\n[features]\nsemantic = true\nscratchpad = false",
)
.unwrap();
assert_eq!(c.features.get(F::Semantic), Some(true));
assert_eq!(c.features.get(F::Scratchpad), Some(false));
assert_eq!(c.features.get(F::ToolOffload), None);
}
#[test]
fn base_for_defaults_tool_offload_on_and_local_assist_on_for_ollama() {
use ContextFeature as F;
let local = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Ollama);
assert!(local.get(F::ToolOffload));
assert!(local.get(F::Scratchpad));
assert!(local.get(F::Semantic));
assert!(local.get(F::Scheduled));
let cloud = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Openai);
assert!(cloud.get(F::ToolOffload));
assert!(cloud.get(F::Scratchpad));
assert!(cloud.get(F::Semantic));
assert!(cloud.get(F::Scheduled));
let mut ov = ContextFeatures::default();
ov.set(F::Scheduled, Some(false));
ov.set(F::ToolOffload, Some(false));
assert!(!ov.apply_to(local).get(F::Scheduled));
assert!(!ov.apply_to(local).get(F::ToolOffload));
assert!(ov.apply_to(local).get(F::Scratchpad)); }
#[test]
fn allow_bang_escape_defaults_to_true_and_round_trips() {
let cfg: TuiConfig = toml::from_str("").unwrap();
assert!(cfg.allow_bang_escape);
let cfg: TuiConfig = toml::from_str("allow_bang_escape = false").unwrap();
assert!(!cfg.allow_bang_escape);
}
#[test]
fn shell_commands_default_on_mutations_default_off_and_round_trip() {
let cfg: TuiConfig = toml::from_str("").unwrap();
assert!(cfg.allow_shell_commands);
assert!(!cfg.allow_shell_mutations);
let cfg: TuiConfig =
toml::from_str("allow_shell_commands = false\nallow_shell_mutations = true").unwrap();
assert!(!cfg.allow_shell_commands);
assert!(cfg.allow_shell_mutations);
}
#[test]
fn thinking_mode_defaults_to_stream_and_round_trips() {
let cfg: TuiConfig = toml::from_str("").unwrap();
assert_eq!(cfg.thinking, ThinkingMode::Stream);
let cfg: TuiConfig = toml::from_str("thinking = \"off\"").unwrap();
assert_eq!(cfg.thinking, ThinkingMode::Off);
let cfg: TuiConfig = toml::from_str("thinking = \"stream\"").unwrap();
assert_eq!(cfg.thinking, ThinkingMode::Stream);
}
#[test]
fn profile_parses_techniques_and_knobs() {
let cfg: Config = toml::from_str(
r#"
[profiles.nemotron]
techniques = ["knowledge_base", "verify_gate", "retry"]
[profiles.nemotron.verify_gate]
surface_match = "exact"
[profiles.nemotron.retry]
max_retries = 3
"#,
)
.unwrap();
let p = &cfg.profiles["nemotron"];
assert!(p.validate().is_ok());
assert!(p.enables("verify_gate") && p.enables("retry"));
assert_eq!(
p.verify_gate_knobs().surface_match,
crate::verify_gate::SurfaceMatch::Exact
);
assert_eq!(p.retry_knobs().max_retries, 3);
}
#[test]
fn profile_knobs_default_when_unset() {
let p: ProfileConfig = toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
assert_eq!(
p.verify_gate_knobs().surface_match,
crate::verify_gate::SurfaceMatch::Exact );
assert_eq!(p.retry_knobs().max_retries, 2);
}
#[test]
fn profile_rejects_unknown_technique() {
let p: ProfileConfig =
toml::from_str("techniques = [\"knowledge_base\", \"teleport\"]").unwrap();
let err = p.validate().unwrap_err();
assert!(err.contains("teleport"), "err: {err}");
}
#[test]
fn profile_rejects_unmet_presupposition() {
let p: ProfileConfig = toml::from_str("techniques = [\"retry\"]").unwrap();
let err = p.validate().unwrap_err();
assert!(
err.contains("retry") && err.contains("verify_gate") && err.contains("presupposes"),
"err: {err}"
);
let ok: ProfileConfig =
toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
assert!(ok.validate().is_ok());
}
#[test]
fn registry_does_not_alter_the_resolved_technique_set() {
let p: ProfileConfig =
toml::from_str("techniques = [\"knowledge_base\", \"verify_gate\", \"retry\"]")
.unwrap();
assert!(p.validate().is_ok());
assert_eq!(p.techniques, vec!["knowledge_base", "verify_gate", "retry"]);
for t in ["knowledge_base", "verify_gate", "retry"] {
assert!(p.enables(t));
}
}
#[test]
fn empty_profiles_is_the_default() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.profiles.is_empty());
assert!(cfg.bundles.is_empty());
}
fn bundle_cfg() -> Config {
toml::from_str(
r#"
[profiles.nemotron]
techniques = ["knowledge_base", "verify_gate", "retry"]
[profiles.qwen-coder]
techniques = []
[bundles.nemotron]
about = "nemotron family support"
applies_to = ["nemotron"]
default_profile = "nemotron"
families = { "nemotron" = "nemotron", "qwen" = "qwen-coder" }
[bundles.review-heavy] # use-case bundle: no applies_to
default_profile = "nemotron"
"#,
)
.unwrap()
}
#[test]
fn resolve_bundle_errors_on_unknown() {
let cfg = bundle_cfg();
assert!(cfg.resolve_bundle("nemotron").is_ok());
let err = cfg.resolve_bundle("ghost").unwrap_err();
assert!(err.contains("no such bundle"), "{err}");
}
#[test]
fn bundle_profile_for_model_longest_prefix_then_default() {
let cfg = bundle_cfg();
let b = cfg.resolve_bundle("nemotron").unwrap();
assert_eq!(
cfg.bundle_profile_for_model(b, "nemotron3:33b"),
Some("nemotron")
);
assert_eq!(
cfg.bundle_profile_for_model(b, "qwen2.5-coder"),
Some("qwen-coder")
);
assert_eq!(
cfg.bundle_profile_for_model(b, "llama3.1:8b"),
Some("nemotron")
);
}
#[test]
fn infer_bundle_only_from_applies_to() {
let cfg = bundle_cfg();
assert_eq!(
cfg.infer_bundle("nemotron3:33b").map(|(n, _)| n),
Some("nemotron")
);
assert!(cfg.infer_bundle("gpt-4.1").is_none());
}
#[test]
fn pick_active_profile_precedence() {
let cfg = bundle_cfg();
let p = cfg
.pick_active_profile(Some("qwen-coder"), Some("nemotron"), "nemotron3:33b")
.unwrap()
.unwrap();
assert_eq!(p.name, "qwen-coder");
assert_eq!(p.via, PickVia::Profile);
let p = cfg
.pick_active_profile(None, Some("nemotron"), "nemotron3:33b")
.unwrap()
.unwrap();
assert_eq!(
(p.name.as_str(), p.via),
("nemotron", PickVia::Bundle("nemotron".into()))
);
let p = cfg
.pick_active_profile(None, None, "nemotron3:33b")
.unwrap()
.unwrap();
assert_eq!(p.via, PickVia::InferredBundle("nemotron".into()));
assert!(cfg
.pick_active_profile(None, None, "gpt-4.1")
.unwrap()
.is_none());
assert!(cfg.pick_active_profile(None, Some("ghost"), "x").is_err());
}
#[test]
fn loadout_parses_inline_and_validates_references() {
let cfg: Config = toml::from_str(
r#"
[[backends]]
name = "dgx"
endpoint = "http://dgx.local:11434"
model = "nemotron-3:33b"
tiers = []
[profiles.nemotron]
techniques = ["knowledge_base", "verify_gate", "retry"]
[bundles.nemotron]
default_profile = "nemotron"
[loadouts.dev-nemotron]
provider = "dgx"
model = "nemotron@deep"
kit = "nemotron"
profile = "nemotron"
role = "python-developer"
[loadouts.dev-nemotron.settings]
num_ctx = 24576
framing = "Ship small, verify."
"#,
)
.unwrap();
let l = &cfg.loadouts["dev-nemotron"];
assert_eq!(l.provider.as_deref(), Some("dgx"));
assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
assert_eq!(l.role.as_deref(), Some("python-developer"));
assert_eq!(l.settings.as_ref().unwrap().num_ctx, Some(24576));
assert!(l.validate(&cfg).is_ok());
}
#[test]
fn loadout_rejects_dangling_references() {
let cfg: Config = toml::from_str(
r#"
[[backends]]
name = "real-box"
endpoint = "http://h:11434"
model = "m"
[profiles.nemotron]
techniques = ["verify_gate"]
"#,
)
.unwrap();
let bad_kit = Loadout {
kit: Some("ghost-bundle".into()),
..Default::default()
};
let e = bad_kit.validate(&cfg).unwrap_err();
assert!(
e.contains("kit 'ghost-bundle'") && e.contains("no such bundle"),
"{e}"
);
let bad_profile = Loadout {
profile: Some("ghost-profile".into()),
..Default::default()
};
let e = bad_profile.validate(&cfg).unwrap_err();
assert!(
e.contains("profile 'ghost-profile'") && e.contains("no such profile"),
"{e}"
);
let bad_provider = Loadout {
provider: Some("ghost-provider".into()),
..Default::default()
};
let e = bad_provider.validate(&cfg).unwrap_err();
assert!(
e.contains("provider 'ghost-provider'")
&& e.contains("no [backends] entry")
&& e.contains("real-box"),
"{e}"
);
assert!(Loadout::default().validate(&cfg).is_ok());
}
#[test]
fn disk_bundles_load_per_file_by_stem() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("nemotron.toml"),
"applies_to = [\"nemotron\"]\ndefault_profile = \"nemotron\"\n",
)
.unwrap();
std::fs::write(
dir.path().join("broken.toml"),
"applies_to = \"not-a-list\"\n",
)
.unwrap();
std::fs::write(dir.path().join("README.md"), "not a bundle").unwrap();
let mut cfg = Config::default();
cfg.merge_bundles_from_dir(dir.path());
assert_eq!(cfg.bundles.len(), 1, "only the valid .toml loads");
let b = cfg
.bundles
.get("nemotron")
.expect("loaded by filename stem");
assert_eq!(b.applies_to, vec!["nemotron"]);
assert_eq!(b.default_profile.as_deref(), Some("nemotron"));
cfg.bundles.insert("x".into(), BundleConfig::default());
std::fs::write(dir.path().join("x.toml"), "about = \"from disk\"\n").unwrap();
cfg.merge_bundles_from_dir(dir.path());
assert_eq!(cfg.bundles["x"].about.as_deref(), Some("from disk"));
}
#[test]
fn disk_loadouts_load_per_file_by_stem() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("dev-nemotron.toml"),
"provider = \"dgx\"\nmodel = \"nemotron@deep\"\nkit = \"nemotron\"\n",
)
.unwrap();
std::fs::write(
dir.path().join("broken.toml"),
"provider = [\"not-a-string\"]\n",
)
.unwrap();
std::fs::write(dir.path().join("README.md"), "not a loadout").unwrap();
let mut cfg = Config::default();
cfg.merge_loadouts_from_dir(dir.path());
assert_eq!(cfg.loadouts.len(), 1, "only the valid .toml loads");
let l = cfg
.loadouts
.get("dev-nemotron")
.expect("loaded by filename stem");
assert_eq!(l.provider.as_deref(), Some("dgx"));
assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
assert_eq!(l.kit.as_deref(), Some("nemotron"));
cfg.loadouts.insert("x".into(), Loadout::default());
std::fs::write(dir.path().join("x.toml"), "role = \"from-disk\"\n").unwrap();
cfg.merge_loadouts_from_dir(dir.path());
assert_eq!(cfg.loadouts["x"].role.as_deref(), Some("from-disk"));
}
#[test]
fn crew_parses_inline_and_validates_role_references() {
let cfg: Config = toml::from_str(
r#"
[[backends]]
name = "dgx"
endpoint = "http://dgx.local:11434"
model = "qwen3-coder:30b"
tiers = []
[[backends]]
name = "gnuc"
endpoint = "http://localhost:11434"
model = "qwen2.5-coder:3b"
tiers = []
[loadouts.planner]
provider = "dgx"
[loadouts.navigator]
provider = "dgx"
[loadouts.triage]
provider = "gnuc"
[crews.coder]
planner = "planner"
navigator = "navigator"
triage = "triage"
loop = "patch-revise"
[crews.coder.budgets]
max_attempts = 4
require_human_review_on = ["auth", "crypto"]
"#,
)
.unwrap();
let c = &cfg.crews["coder"];
assert_eq!(c.planner, "planner");
assert_eq!(c.navigator.as_deref(), Some("navigator"));
assert_eq!(c.loop_program.as_deref(), Some("patch-revise"));
assert_eq!(c.budgets.as_ref().unwrap().max_attempts, Some(4));
assert!(c.validate(&cfg).is_ok());
}
#[test]
fn crew_rejects_dangling_and_invalid_roles() {
let cfg: Config = toml::from_str(
r#"
[[backends]]
name = "dgx"
endpoint = "http://dgx.local:11434"
model = "m"
tiers = []
[loadouts.planner]
provider = "dgx"
"#,
)
.unwrap();
let dangling = Crew {
planner: "planner".into(),
triage: Some("ghost".into()),
..Default::default()
};
let e = dangling.validate(&cfg).unwrap_err();
assert!(e.contains("triage 'ghost'"), "{e}");
assert!(e.contains("no [loadouts]"), "{e}");
let mut cfg2 = cfg.clone();
cfg2.loadouts.insert(
"bad".into(),
Loadout {
provider: Some("nope".into()),
..Default::default()
},
);
let transitive = Crew {
planner: "bad".into(),
..Default::default()
};
let e = transitive.validate(&cfg2).unwrap_err();
assert!(
e.contains("planner 'bad'") && e.contains("provider 'nope'"),
"{e}"
);
}
#[test]
fn disk_crews_load_per_file_by_stem() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("coder.toml"),
"planner = \"planner\"\nnavigator = \"navigator\"\n",
)
.unwrap();
std::fs::write(dir.path().join("broken.toml"), "navigator = \"x\"\n").unwrap();
std::fs::write(dir.path().join("README.md"), "not a crew").unwrap();
let mut cfg = Config::default();
cfg.merge_crews_from_dir(dir.path());
assert_eq!(cfg.crews.len(), 1, "only the valid .toml loads");
let c = cfg.crews.get("coder").expect("loaded by filename stem");
assert_eq!(c.planner, "planner");
cfg.crews.insert(
"coder".into(),
Crew {
planner: "inline".into(),
..Default::default()
},
);
cfg.merge_crews_from_dir(dir.path());
assert_eq!(cfg.crews["coder"].planner, "planner", "disk wins");
}
#[test]
fn backend_api_axis_defaults_and_parses() {
let def: BackendConfig =
toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\nkind=\"openai\"\n").unwrap();
assert_eq!(def.api, None);
let resp: BackendConfig = toml::from_str(
"endpoint=\"http://h:1\"\nmodel=\"gpt-5-codex\"\nkind=\"openai\"\napi=\"responses\"\n",
)
.unwrap();
assert_eq!(resp.api, Some(OpenAiApi::Responses));
let alias: BackendConfig =
toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\napi=\"chat\"\n").unwrap();
assert_eq!(alias.api, Some(OpenAiApi::ChatCompletions));
}
#[test]
fn discovery_defaults_cover_localhost_unboxing() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.discovery.hosts, vec!["localhost".to_string()]);
assert_eq!(cfg.discovery.ollama_ports, vec![11434]);
assert_eq!(cfg.discovery.vllm_ports, vec![8000, 8080, 8001, 8002, 8003]);
assert_eq!(cfg.default_backend, None);
let cfg: Config = toml::from_str(
"default_backend=\"dgx1-vllm\"\n[discovery]\nhosts=[\"localhost\",\"dgx1\"]\nvllm_ports=[8000]\n",
)
.unwrap();
assert_eq!(cfg.default_backend.as_deref(), Some("dgx1-vllm"));
assert_eq!(cfg.discovery.hosts.len(), 2);
assert_eq!(cfg.discovery.vllm_ports, vec![8000]);
assert_eq!(cfg.discovery.ollama_ports, vec![11434]);
}
#[test]
fn serving_axis_fields_round_trip_and_stay_minimal() {
let legacy: BackendConfig =
toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\n").unwrap();
assert_eq!(legacy.serving, None);
assert_eq!(legacy.host, None);
assert_eq!(legacy.coexist, None);
let full: BackendConfig = toml::from_str(
"endpoint=\"http://dgx:8000\"\nkind=\"openai\"\nserving=\"instance\"\n\
host=\"dgx1\"\ncoexist=true\nram_gib=480.0\ncard=\"ornith-1.0-35b\"\n\
[capability]\nthinking_default=true\n\
[provenance]\nsource=\"newt setup v0.7.3\"\nderived_serving=true\n",
)
.unwrap();
assert_eq!(full.serving, Some(Serving::Instance));
assert_eq!(full.host.as_deref(), Some("dgx1"));
assert_eq!(full.coexist, Some(true));
assert_eq!(full.ram_gib, Some(480.0));
assert_eq!(full.card.as_deref(), Some("ornith-1.0-35b"));
assert_eq!(
full.capability.as_ref().and_then(|c| c.thinking_default),
Some(true)
);
assert_eq!(
full.provenance.as_ref().and_then(|p| p.derived_serving),
Some(true)
);
let out = toml::to_string(&legacy).unwrap();
assert!(!out.contains("serving"), "unset fields are skipped: {out}");
assert!(!out.contains("provenance"));
}
#[test]
fn derive_serving_rules() {
assert_eq!(derive_serving(BackendKind::Ollama, 1), Serving::Multiplexer);
assert_eq!(derive_serving(BackendKind::Ollama, 7), Serving::Multiplexer);
assert_eq!(derive_serving(BackendKind::Openai, 1), Serving::Instance);
assert_eq!(derive_serving(BackendKind::Openai, 3), Serving::Multiplexer);
assert_eq!(derive_serving(BackendKind::Embedded, 1), Serving::Instance);
}
#[test]
fn mcp_stdio_env_allowlist_excludes_secrets_and_is_closed() {
let allow = mcp_stdio_env_passthrough();
for secret in [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"GITHUB_TOKEN",
"DGX_API_KEY",
"NVIDIA_API_KEY",
] {
assert!(!allow.contains(&secret), "{secret} must never be inherited");
}
assert!(allow.contains(&"PATH"), "a child needs PATH to exec");
for base in shell_env_passthrough_default() {
assert!(
allow.contains(&base.as_str()),
"{base} (shell default) should be covered"
);
}
}
#[test]
fn backend_model_is_optional_and_read_via_effective_model() {
let serverless: BackendConfig =
toml::from_str("endpoint=\"http://h:8000\"\nkind=\"openai\"\n").unwrap();
assert_eq!(serverless.model, None);
assert_eq!(serverless.effective_model(), None);
let pinned: BackendConfig =
toml::from_str("endpoint=\"http://h:1\"\nmodel=\"qwen3:32b\"\n").unwrap();
assert_eq!(pinned.effective_model(), Some("qwen3:32b"));
let empty: BackendConfig = toml::from_str("endpoint=\"http://h:1\"\nmodel=\"\"\n").unwrap();
assert_eq!(empty.effective_model(), None);
}
#[test]
fn disk_backends_load_per_file_by_stem_and_override_inline() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("dgx1.toml"),
"endpoint = \"http://REDACTED-HOST:11434\"\nmodel = \"qwen3:30b\"\n",
)
.unwrap();
std::fs::write(dir.path().join("broken.toml"), "model = \"x\"\n").unwrap();
std::fs::write(dir.path().join("README.md"), "not a backend").unwrap();
let mut cfg = Config {
backends: vec![
BackendConfig {
name: "dgx1".into(),
endpoint: "http://stale:11434".into(),
model: Some("old-model".into()),
model_path: None,
tiers: vec![],
kind: Some(BackendKind::Ollama),
api: Default::default(),
api_key_file: None,
api_key_env: None,
..Default::default()
},
BackendConfig {
name: "gnuc".into(),
endpoint: "http://gnuc:11434".into(),
model: Some("qwen2.5-coder:14b".into()),
model_path: None,
tiers: vec![],
kind: Some(BackendKind::Ollama),
api: Default::default(),
api_key_file: None,
api_key_env: None,
..Default::default()
},
],
..Default::default()
};
cfg.merge_backends_from_dir(dir.path());
assert_eq!(cfg.backends.len(), 2, "only the valid .toml loads, no dup");
let dgx1 = cfg.backends.iter().find(|b| b.name == "dgx1").unwrap();
assert_eq!(dgx1.endpoint, "http://REDACTED-HOST:11434", "disk wins");
assert_eq!(dgx1.effective_model(), Some("qwen3:30b"));
assert_eq!(dgx1.kind, None, "absent kind means probe-at-connect");
assert!(cfg.backends.iter().any(|b| b.name == "gnuc"), "gnuc kept");
}
#[test]
fn dropin_probe_cache_preserves_config_declared_auth() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("gpt41.toml"),
"endpoint = \"https://api.openai.com\"\nmodel = \"gpt-4.1\"\nkind = \"openai\"\n",
)
.unwrap();
let mut cfg = Config {
backends: vec![BackendConfig {
name: "gpt41".into(),
endpoint: "https://api.openai.com".into(),
model: Some("gpt-4.1".into()),
kind: Some(BackendKind::Openai),
api_key_env: Some("OPENAI_API_KEY".into()),
api_key_file: Some("/vault/openai".into()),
..Default::default()
}],
..Default::default()
};
cfg.merge_backends_from_dir(dir.path());
let b = cfg.backends.iter().find(|b| b.name == "gpt41").unwrap();
assert_eq!(b.model.as_deref(), Some("gpt-4.1"), "probed model applied");
assert_eq!(
b.api_key_env.as_deref(),
Some("OPENAI_API_KEY"),
"config-declared api_key_env must survive a keyless probe drop-in"
);
assert_eq!(
b.api_key_file.as_deref(),
Some("/vault/openai"),
"config-declared api_key_file must survive a keyless probe drop-in"
);
}
#[test]
fn dropin_probe_cache_preserves_config_declared_tiers() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("eval.toml"),
"endpoint = \"http://router:8080\"\nmodel = \"big-30b\"\nkind = \"openai\"\ntiers = []\n",
)
.unwrap();
let mut cfg = Config {
backends: vec![BackendConfig {
name: "eval".into(),
endpoint: "http://router:8080".into(),
model: Some("big-30b".into()),
kind: Some(BackendKind::Openai),
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
..Default::default()
}],
..Default::default()
};
cfg.merge_backends_from_dir(dir.path());
let b = cfg.backends.iter().find(|b| b.name == "eval").unwrap();
assert_eq!(b.model.as_deref(), Some("big-30b"), "probed model applied");
assert_eq!(
b.tiers,
vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
"config-declared tiers must survive a probe drop-in that omits them (tiers=[])"
);
}
#[test]
fn cli_backend_override_with_endpoint_is_exclusive_and_defaults_tiers() {
let mut cfg = Config {
backends: vec![
BackendConfig {
name: "discovered-ollama".into(),
endpoint: "http://localhost:11434".into(),
kind: Some(BackendKind::Ollama),
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
..Default::default()
},
fallback_localhost_backend(),
],
..Default::default()
};
let over = BackendOverride {
endpoint: Some("http://router:8080".into()),
model: Some("big-30b".into()),
kind: Some(BackendKind::Openai),
..Default::default()
};
over.apply(&mut cfg);
assert_eq!(cfg.backends.len(), 1, "CLI endpoint is exclusive");
let b = &cfg.backends[0];
assert_eq!(b.name, "cli");
assert_eq!(b.endpoint, "http://router:8080");
assert_eq!(b.model.as_deref(), Some("big-30b"));
assert_eq!(b.kind, Some(BackendKind::Openai));
assert_eq!(
b.tiers,
vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
"an exclusive CLI backend defaults to all tiers so it serves"
);
}
#[test]
fn cli_backend_override_field_only_edits_first_backend_in_place() {
let mut cfg = Config {
backends: vec![BackendConfig {
name: "eval".into(),
endpoint: "http://router:8080".into(),
model: Some("old".into()),
kind: Some(BackendKind::Openai),
tiers: vec![Tier::Fast],
..Default::default()
}],
..Default::default()
};
let over = BackendOverride {
model: Some("new-model".into()),
..Default::default()
};
over.apply(&mut cfg);
assert_eq!(cfg.backends.len(), 1, "no new backend added");
assert_eq!(cfg.backends[0].name, "eval", "existing backend kept");
assert_eq!(cfg.backends[0].endpoint, "http://router:8080");
assert_eq!(cfg.backends[0].model.as_deref(), Some("new-model"));
}
#[test]
fn cli_backend_override_empty_is_a_noop() {
let mut cfg = Config {
backends: vec![fallback_localhost_backend()],
..Default::default()
};
let before: Vec<(String, String)> = cfg
.backends
.iter()
.map(|b| (b.name.clone(), b.endpoint.clone()))
.collect();
BackendOverride::default().apply(&mut cfg);
let after: Vec<(String, String)> = cfg
.backends
.iter()
.map(|b| (b.name.clone(), b.endpoint.clone()))
.collect();
assert_eq!(after, before, "an empty override changes nothing");
}
#[test]
fn writeback_probed_backend_lands_in_dedicated_dropin_not_config_toml() {
let dir = tempfile::tempdir().unwrap();
let config_toml = dir.path().join("config.toml");
std::fs::write(&config_toml, "# keep me\n").unwrap();
let prev = std::env::var_os(NEWT_CONFIG_DIR_ENV);
unsafe { std::env::set_var(NEWT_CONFIG_DIR_ENV, dir.path()) };
let patch = BackendConfig {
name: "dgx1-llama".into(),
endpoint: "http://host:8000".into(),
kind: Some(BackendKind::Openai),
api: Some(OpenAiApi::Responses),
model: Some("nemotron".into()),
serving: Some(Serving::Instance),
..Default::default()
};
let written = writeback_probed_backend(&patch)
.unwrap()
.expect("user config dir is set");
assert_eq!(written, dir.path().join("backends").join("dgx1-llama.toml"));
let body = std::fs::read_to_string(&written).unwrap();
assert!(body.contains("kind = \"openai\""));
assert!(body.contains("api = \"responses\""));
assert!(body.contains("model = \"nemotron\""));
assert!(body.contains("serving = \"instance\""));
assert_eq!(
std::fs::read_to_string(&config_toml).unwrap(),
"# keep me\n"
);
std::fs::write(
&written,
"endpoint = \"http://host:8000\"\napi_key_env = \"DGX_TOKEN\"\n",
)
.unwrap();
let patch2 = BackendConfig {
name: "dgx1-llama".into(),
endpoint: "http://host:8000".into(),
kind: Some(BackendKind::Openai),
api: Some(OpenAiApi::ChatCompletions),
model: Some("other".into()),
..Default::default()
};
writeback_probed_backend(&patch2).unwrap();
let body2 = std::fs::read_to_string(&written).unwrap();
assert!(body2.contains("api_key_env"), "auth ref preserved: {body2}");
assert!(body2.contains("api = \"chat_completions\""));
assert!(body2.contains("model = \"other\""));
match prev {
Some(v) => unsafe { std::env::set_var(NEWT_CONFIG_DIR_ENV, v) },
None => unsafe { std::env::remove_var(NEWT_CONFIG_DIR_ENV) },
}
}
#[test]
fn disk_dgx_nodes_load_per_file_by_stem_and_override_inline() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("dgx1.toml"),
"ollama = \"http://REDACTED-HOST:11434\"\n\
vllm = \"http://REDACTED-HOST:8000\"\n\
ssh_host = \"REDACTED-HOST\"\n",
)
.unwrap();
std::fs::write(dir.path().join("README.md"), "not a node").unwrap();
let mut cfg = Config::default();
assert!(cfg.dgx.is_none());
cfg.merge_dgx_nodes_from_dir(dir.path());
let dgx = cfg.dgx.as_ref().expect("[dgx] created from drop-ins");
assert_eq!(dgx.nodes.len(), 1);
let node = &dgx.nodes[0];
assert_eq!(node.name, "dgx1", "name comes from the filename stem");
assert_eq!(node.ollama.as_deref(), Some("http://REDACTED-HOST:11434"));
assert_eq!(node.vllm.as_deref(), Some("http://REDACTED-HOST:8000"));
assert_eq!(node.ssh_host.as_deref(), Some("REDACTED-HOST"));
assert_eq!(dgx.active_node().unwrap().name, "dgx1");
cfg.dgx.as_mut().unwrap().nodes[0].ollama = Some("http://stale:1".into());
cfg.merge_dgx_nodes_from_dir(dir.path());
assert_eq!(cfg.dgx.as_ref().unwrap().nodes.len(), 1, "no duplicate");
assert_eq!(
cfg.dgx.unwrap().nodes[0].ollama.as_deref(),
Some("http://REDACTED-HOST:11434"),
"disk wins"
);
}
#[test]
fn backendless_config_deserializes_empty_but_default_keeps_fallback() {
let cfg: Config = toml::from_str("providers = []\n").unwrap();
assert!(
cfg.backends.is_empty(),
"absent [[backends]] deserializes to empty, got {:?}",
cfg.backends
);
assert_eq!(Config::default().backends.len(), 1);
assert_eq!(Config::default().backends[0].name, "ollama");
let inline: Config =
toml::from_str("[[backends]]\nname=\"x\"\nendpoint=\"http://h:1\"\nmodel=\"m\"\n")
.unwrap();
assert_eq!(inline.backends.len(), 1);
assert_eq!(inline.backends[0].name, "x");
}
#[test]
fn surface_match_round_trips_lowercase() {
let k: VerifyGateKnobs = toml::from_str("surface_match = \"prefix\"").unwrap();
assert_eq!(k.surface_match, crate::verify_gate::SurfaceMatch::Prefix);
}
#[test]
fn resolve_profile_looks_up_validates_and_errors() {
let cfg: Config = toml::from_str(
r#"
[profiles.nemotron]
techniques = ["verify_gate"]
[profiles.bad]
techniques = ["teleport"]
"#,
)
.unwrap();
assert!(cfg
.resolve_profile("nemotron")
.unwrap()
.enables("verify_gate"));
assert!(cfg.resolve_profile("bad").unwrap_err().contains("teleport"));
let err = cfg.resolve_profile("ghost").unwrap_err();
assert!(
err.contains("no such profile") && err.contains("nemotron"),
"err: {err}"
);
}
#[test]
fn memory_note_nudge_interval_defaults_and_parses() {
assert_eq!(MemoryConfig::default().note_nudge_interval, 10);
let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
assert_eq!(cfg.note_nudge_interval, 10);
let cfg: MemoryConfig = toml::from_str("note_nudge_interval = 0").unwrap();
assert_eq!(cfg.note_nudge_interval, 0);
}
#[test]
fn memory_extract_notes_on_close_defaults_off_and_parses() {
assert!(!MemoryConfig::default().extract_notes_on_close);
let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
assert!(!cfg.extract_notes_on_close);
let cfg: MemoryConfig = toml::from_str("extract_notes_on_close = true").unwrap();
assert!(cfg.extract_notes_on_close);
}
#[test]
fn memory_disclosure_defaults_to_frozen_and_parses_index() {
assert_eq!(MemoryConfig::default().disclosure, MemoryDisclosure::Frozen);
let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
let cfg: MemoryConfig = toml::from_str("disclosure = \"index\"").unwrap();
assert_eq!(cfg.disclosure, MemoryDisclosure::Index);
let cfg: MemoryConfig = toml::from_str("disclosure = \"frozen\"").unwrap();
assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
}
#[test]
fn skill_search_dirs_defaults_to_single_newt_dir() {
let cfg = Config::default();
let dirs = cfg.skill_search_dirs();
assert_eq!(dirs.len(), 1);
assert!(dirs[0].ends_with("skills"));
assert_eq!(
dirs[0].parent().and_then(|p| p.file_name()),
Some(".newt".as_ref())
);
}
#[test]
fn personas_dir_is_a_sibling_of_the_newt_config_dir() {
let dir = Config::personas_dir();
assert!(dir.ends_with("personas"));
assert_eq!(
dir.parent().and_then(|p| p.file_name()),
Some(".newt".as_ref())
);
}
#[test]
fn skill_search_dirs_preserves_configured_order() {
let cfg = Config {
skills: Some(SkillsConfig {
search: vec!["/abs/one".into(), "/abs/two".into()],
bundled_dir: String::new(),
}),
..Config::default()
};
assert_eq!(
cfg.skill_search_dirs(),
vec![PathBuf::from("/abs/one"), PathBuf::from("/abs/two")]
);
}
#[test]
fn skill_search_dirs_expands_tilde() {
let cfg = Config {
skills: Some(SkillsConfig {
search: vec!["~/skills-x".into()],
bundled_dir: String::new(),
}),
..Config::default()
};
let dirs = cfg.skill_search_dirs();
assert!(dirs[0].ends_with("skills-x"));
assert!(!dirs[0].starts_with("~"));
}
#[test]
fn skill_search_dirs_appends_bundled_dir_last() {
let cfg = Config {
skills: Some(SkillsConfig {
search: vec!["/abs/user".into()],
bundled_dir: "/abs/bundled".into(),
}),
..Config::default()
};
assert_eq!(
cfg.skill_search_dirs(),
vec![PathBuf::from("/abs/user"), PathBuf::from("/abs/bundled")],
"user search dirs must precede the bundled dir so users can override"
);
}
#[test]
fn skill_search_dirs_bundled_after_default_when_search_empty() {
let with_bundled = Config {
skills: Some(SkillsConfig {
search: vec![],
bundled_dir: "/abs/bundled".into(),
}),
..Config::default()
};
let dirs = with_bundled.skill_search_dirs();
assert_eq!(dirs.len(), 2, "default host dir + bundled: {dirs:?}");
assert!(
dirs[0].ends_with("skills"),
"default host dir first: {dirs:?}"
);
assert_eq!(
dirs[1],
PathBuf::from("/abs/bundled"),
"bundled last: {dirs:?}"
);
let no_bundled = Config {
skills: Some(SkillsConfig {
search: vec![],
bundled_dir: String::new(),
}),
..Config::default()
};
assert_eq!(
no_bundled.skill_search_dirs().len(),
1,
"empty bundled_dir contributes no directory"
);
}
#[test]
fn find_ancestor_dir_returns_first_matching_ancestor() {
let root = Path::new("/home/u/repo");
let target = root.join(".newt/bundled-skills");
let exists = |p: &Path| p == target;
let got = find_ancestor_dir(
Path::new("/home/u/repo/newt-core/src"),
Path::new(".newt/bundled-skills"),
exists,
);
assert_eq!(got, Some(target));
}
#[test]
fn find_ancestor_dir_none_when_no_ancestor_has_it() {
let got = find_ancestor_dir(
Path::new("/home/u/repo/newt-core/src"),
Path::new(".newt/bundled-skills"),
|_| false,
);
assert_eq!(got, None, "no ancestor matches → None, never a bogus path");
}
#[test]
fn with_bundled_default_leaves_a_configured_value_untouched() {
let cfg = Config {
skills: Some(SkillsConfig {
search: vec![],
bundled_dir: "/explicit/bundled".into(),
}),
..Config::default()
}
.with_bundled_default();
assert_eq!(
cfg.skills.unwrap().bundled_dir,
"/explicit/bundled",
"an explicitly configured bundled_dir is never overridden"
);
}
#[test]
fn skills_search_round_trips_through_toml() {
let cfg = Config {
skills: Some(SkillsConfig {
search: vec!["~/.newt/skills".into(), "~/.claude/skills".into()],
bundled_dir: String::new(),
}),
..Config::default()
};
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
assert_eq!(
back.skills.unwrap().search,
vec!["~/.newt/skills".to_string(), "~/.claude/skills".to_string()]
);
}
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 conversations_config_defaults_to_count_cap() {
let cfg = Config::default();
let conversations = cfg.conversations.unwrap_or_default();
assert_eq!(conversations.max_per_workspace, 100);
assert!(!conversations.resume);
}
#[test]
fn conversations_config_roundtrips_through_toml() {
let cfg: Config = toml::from_str(
r#"
[conversations]
max_per_workspace = 25
"#,
)
.unwrap();
let conversations = cfg.conversations.unwrap_or_default();
assert_eq!(conversations.max_per_workspace, 25);
assert!(!conversations.resume);
}
#[test]
fn conversations_resume_opt_in_parses() {
let cfg: Config = toml::from_str(
r#"
[conversations]
resume = true
"#,
)
.unwrap();
assert!(cfg.conversations.unwrap_or_default().resume);
}
#[test]
fn agents_config_default_enabled() {
let cfg = AgentsConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.path, None);
assert!(Config::default().agents.enabled);
}
#[test]
fn agents_config_roundtrips_with_path() {
let cfg: Config = toml::from_str(
r#"
[agents]
path = "docs/instructions"
"#,
)
.unwrap();
assert!(cfg.agents.enabled);
assert_eq!(cfg.agents.path.as_deref(), Some("docs/instructions"));
let text = toml::to_string(&cfg).unwrap();
assert!(text.contains("docs/instructions"));
}
#[test]
fn agents_config_can_be_disabled() {
let cfg: Config = toml::from_str(
r#"
[agents]
enabled = false
"#,
)
.unwrap();
assert!(!cfg.agents.enabled);
assert_eq!(cfg.agents.path, None);
}
#[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"
model = "gpt-4.1-mini"
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].effective_model(), Some("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].model.as_deref(), Some("gpt-4.1-mini"));
assert_eq!(cfg.providers[0].env_pass, vec!["CLOUD_TOKEN".to_string()]);
}
#[test]
fn provider_model_is_optional_for_legacy_configs() {
let cfg: Config = toml::from_str(
r#"
[[providers]]
name = "legacy-cloud"
command = "newt-cloud-shim"
env_pass = ["CLOUD_TOKEN"]
tiers = ["COMPLEX"]
"#,
)
.unwrap();
assert_eq!(cfg.providers.len(), 1);
assert_eq!(cfg.providers[0].model, None);
}
#[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 merge_toml_recurses_tables_and_replaces_scalars() {
let mut base: toml::Value = toml::from_str(
"a = 1\nb = 2\n[tui]\nmid_loop_trim_threshold = 40\nmax_tool_rounds = 25\n",
)
.unwrap();
let overlay: toml::Value =
toml::from_str("b = 99\nc = 3\n[tui]\nmax_tool_rounds = 5\n").unwrap();
merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
assert_eq!(base["a"].as_integer(), Some(1));
assert_eq!(base["b"].as_integer(), Some(99));
assert_eq!(base["c"].as_integer(), Some(3));
assert_eq!(base["tui"]["max_tool_rounds"].as_integer(), Some(5));
assert_eq!(
base["tui"]["mid_loop_trim_threshold"].as_integer(),
Some(40)
);
}
#[test]
fn merge_toml_replaces_arrays_wholesale_by_default() {
let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\", \"c\"]").unwrap();
let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
let arr = base["models"].as_array().unwrap();
assert_eq!(arr.len(), 1, "replace strategy swaps the array");
assert_eq!(arr[0].as_str(), Some("x"));
}
#[test]
fn merge_toml_appends_arrays_when_strategy_is_append() {
let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\"]").unwrap();
let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
merge_toml(&mut base, overlay, ArrayMergeStrategy::Append);
let arr = base["models"].as_array().unwrap();
let got: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(got, vec!["a", "b", "x"]);
}
fn mcp_entry(name: &str) -> crate::mcp::McpServerEntry {
crate::mcp::McpServerEntry {
name: name.into(),
enabled: true,
transport: crate::mcp::TransportKind::Stdio,
command: Some("true".into()),
args: vec![],
env: std::collections::BTreeMap::new(),
url: None,
headers: std::collections::BTreeMap::new(),
request_timeout_secs: None,
trust: crate::mcp::McpTrust::Trusted,
}
}
#[test]
fn mark_project_mcp_untrusted_replace_marks_every_entry() {
let mut servers = vec![mcp_entry("a"), mcp_entry("b")];
mark_project_mcp_untrusted(&mut servers, ArrayMergeStrategy::Replace, Some(2));
assert!(
servers
.iter()
.all(|e| e.trust == crate::mcp::McpTrust::Untrusted),
"replace ⇒ all project-origin ⇒ all untrusted"
);
}
#[test]
fn mark_project_mcp_untrusted_append_marks_only_trailing_project_entries() {
let mut servers = vec![mcp_entry("base"), mcp_entry("p1"), mcp_entry("p2")];
mark_project_mcp_untrusted(&mut servers, ArrayMergeStrategy::Append, Some(2));
assert_eq!(
servers[0].trust,
crate::mcp::McpTrust::Trusted,
"the trusted base entry must stay trusted"
);
assert_eq!(servers[1].trust, crate::mcp::McpTrust::Untrusted);
assert_eq!(servers[2].trust, crate::mcp::McpTrust::Untrusted);
}
#[test]
fn mark_project_mcp_untrusted_none_marks_nothing() {
let mut servers = vec![mcp_entry("a")];
mark_project_mcp_untrusted(&mut servers, ArrayMergeStrategy::Replace, None);
assert_eq!(servers[0].trust, crate::mcp::McpTrust::Trusted);
}
#[test]
fn base_is_ambient_newt_toml_false_for_non_newt_toml_base() {
assert!(!base_is_ambient_newt_toml(None));
assert!(!base_is_ambient_newt_toml(Some(Path::new(
"/etc/newt/config.toml"
))));
assert!(!base_is_ambient_newt_toml(Some(Path::new(
"./newt-other.toml"
))));
}
#[test]
fn array_merge_strategy_project_wins_then_base_then_default() {
let append: toml::Value = toml::from_str("[merge]\narrays = \"append\"\n").unwrap();
let replace: toml::Value = toml::from_str("[merge]\narrays = \"replace\"\n").unwrap();
let none: toml::Value = toml::from_str("x = 1").unwrap();
assert_eq!(
array_merge_strategy(&append, &replace),
ArrayMergeStrategy::Append
);
assert_eq!(
array_merge_strategy(&none, &append),
ArrayMergeStrategy::Append
);
assert_eq!(
array_merge_strategy(&none, &none),
ArrayMergeStrategy::Replace
);
let bogus: toml::Value = toml::from_str("[merge]\narrays = \"sideways\"\n").unwrap();
assert_eq!(
array_merge_strategy(&bogus, &none),
ArrayMergeStrategy::Replace
);
}
#[test]
fn append_strategy_adds_project_mcp_server_to_global() {
let global = "\
[merge]
arrays = \"append\"
[[mcp_servers]]
name = \"global-fs\"
command = \"mcp-fs\"
";
let project = "\
[[mcp_servers]]
name = \"project-fs\"
command = \"mcp-fs\"
args = [\"--root\", \".\"]
";
let mut merged: toml::Value = toml::from_str(global).unwrap();
let proj_val: toml::Value = toml::from_str(project).unwrap();
let strategy = array_merge_strategy(&proj_val, &merged);
assert_eq!(strategy, ArrayMergeStrategy::Append);
merge_toml(&mut merged, proj_val, strategy);
let cfg: Config = merged.try_into().unwrap();
let names: Vec<&str> = cfg.mcp_servers.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, vec!["global-fs", "project-fs"]);
}
#[test]
fn find_project_config_walks_up_and_stops_before_home() {
let home = tempfile::tempdir().unwrap();
let proj = home.path().join("proj");
let sub = proj.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::create_dir_all(proj.join(".newt")).unwrap();
std::fs::write(proj.join(".newt").join("config.toml"), "x = 1").unwrap();
std::fs::create_dir_all(home.path().join(".newt")).unwrap();
std::fs::write(home.path().join(".newt").join("config.toml"), "x = 9").unwrap();
let found = find_project_config_from(&sub, Some(home.path()));
assert_eq!(found, Some(proj.join(".newt").join("config.toml")));
let bare = home.path().join("empty");
std::fs::create_dir_all(&bare).unwrap();
assert_eq!(find_project_config_from(&bare, Some(home.path())), None);
}
#[test]
fn project_config_deep_merges_over_global() {
let global = "\
[[backends]]
name = \"ollama\"
endpoint = \"http://localhost:11434\"
model = \"llama3\"
tiers = []
kind = \"ollama\"
[tui]
mid_loop_trim_threshold = 40
max_tool_rounds = 25
";
let project = "[tui]\nmax_tool_rounds = 7\n";
let mut merged: toml::Value = toml::from_str(global).unwrap();
merge_toml(
&mut merged,
toml::from_str(project).unwrap(),
ArrayMergeStrategy::Replace,
);
let cfg: Config = merged.try_into().unwrap();
assert_eq!(cfg.tui.as_ref().unwrap().max_tool_rounds, 7);
assert_eq!(cfg.tui.as_ref().unwrap().mid_loop_trim_threshold, 40);
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 to_redacted_toml_hides_mcp_secrets_but_keeps_shape() {
let cfg: Config = toml::from_str(
r#"
[[backends]]
name = "remote"
endpoint = "http://remote:8000"
model = "qwen3:32b"
tiers = []
kind = "openai"
api_key_file = "~/.newt/openai.key"
[[mcp_servers]]
name = "gh"
type = "http"
url = "https://api.example/mcp"
[mcp_servers.headers]
Authorization = "Bearer sk-super-secret-token"
[mcp_servers.env]
GH_TOKEN = "ghp_rawsecretvalue"
RUST_LOG = "debug"
"#,
)
.unwrap();
let dump = cfg.to_redacted_toml().unwrap();
assert!(
!dump.contains("sk-super-secret-token"),
"header secret leaked:\n{dump}"
);
assert!(
!dump.contains("ghp_rawsecretvalue"),
"env secret leaked:\n{dump}"
);
assert!(dump.contains("Authorization"));
assert!(dump.contains("GH_TOKEN"));
assert!(dump.contains(Config::REDACTED));
assert!(
dump.contains("~/.newt/openai.key"),
"api_key_file reference kept"
);
assert!(dump.contains("http://remote:8000"));
}
#[test]
fn to_redacted_toml_redacts_literals_but_keeps_secret_references() {
let cfg: Config = toml::from_str(
r#"
[[mcp_servers]]
name = "gh"
command = "gh-mcp"
[mcp_servers.env]
RAW = "ghp_rawinlinesecret"
VAULTED = { cmd = "vault kv get -field=token secret/data/gh" }
[mcp_servers.headers]
Authorization = "Bearer ${cmd:vault kv get -field=token secret/data/api}"
"#,
)
.unwrap();
let dump = cfg.to_redacted_toml().unwrap();
assert!(
!dump.contains("ghp_rawinlinesecret"),
"raw secret leaked:\n{dump}"
);
assert!(
!dump.contains("secret/data/api"),
"interpolation literal leaked:\n{dump}"
);
assert!(dump.contains(Config::REDACTED));
assert!(dump.contains("RAW"));
assert!(dump.contains("VAULTED"));
assert!(dump.contains("Authorization"));
assert!(
dump.contains("vault kv get -field=token secret/data/gh"),
"SecretRef reference kept:\n{dump}"
);
}
#[test]
fn to_redacted_toml_redacts_url_userinfo_query_and_args() {
let cfg: Config = toml::from_str(
r#"
[[mcp_servers]]
name = "gh"
type = "http"
url = "https://alice:sk-URLPASS@api.example/mcp?api_key=SECRETQP®ion=us"
args = ["--token=sk-EQ", "--api-key", "sk-SPACE", "--verbose"]
"#,
)
.unwrap();
let dump = cfg.to_redacted_toml().unwrap();
for leaked in ["sk-URLPASS", "SECRETQP", "sk-EQ", "sk-SPACE", "alice"] {
assert!(!dump.contains(leaked), "`{leaked}` leaked:\n{dump}");
}
assert!(dump.contains("api.example/mcp"), "host/path kept:\n{dump}");
assert!(dump.contains("region=us"), "non-secret param kept:\n{dump}");
assert!(dump.contains("--verbose"), "non-secret arg kept:\n{dump}");
assert!(dump.contains(Config::REDACTED));
}
#[test]
fn redact_url_and_args_helpers_are_precise() {
assert_eq!(
redact_url_secrets("https://u:p@h.example/mcp?token=abc&keep=1#frag"),
format!(
"https://{r}@h.example/mcp?token={r}&keep=1#frag",
r = Config::REDACTED
)
);
assert_eq!(
redact_url_secrets("https://h.example/mcp?region=us"),
"https://h.example/mcp?region=us"
);
assert_eq!(
redact_url_secrets("https://h.example/a@b"),
"https://h.example/a@b"
);
assert_eq!(
redact_arg_secrets(&[
"--token=sk-1".into(),
"--api-key".into(),
"sk-2".into(),
"--model".into(),
"gpt".into(),
]),
vec![
format!("--token={}", Config::REDACTED),
"--api-key".to_string(),
Config::REDACTED.to_string(),
"--model".to_string(),
"gpt".to_string(),
]
);
}
#[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_common_dev_tools() {
let cav = ToolPermissions::default().to_caveats("/workspace");
for tool in [
"gh", "python", "python3", "pip", "npm", "node", "make", "jq", "curl", "awk", "sed",
"cut", "xargs", "which", "env",
] {
assert!(cav.permits_exec(tool), "`{tool}` must be allowed");
}
assert!(!cav.permits_exec("rm"), "rm must still be blocked");
assert!(!cav.permits_exec("sudo"), "sudo must still be blocked");
}
#[test]
fn workspace_dev_allows_extra_exec() {
let perms = ToolPermissions {
preset: PermissionPreset::WorkspaceDev,
extra_exec: vec!["bacon".into(), "make".into()],
net: vec![],
prompt: false,
};
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![],
net: vec![],
prompt: false,
};
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![],
net: vec![],
prompt: false,
};
let cav = perms.to_caveats("/workspace");
assert!(!cav.permits_exec("cargo"));
use crate::caveats::Scope;
assert!(matches!(cav.fs_write, Scope::Only(_)));
}
#[test]
fn mcp_probe_caveats_default_is_read_only_never_top() {
let cav = Config::default().mcp_probe_caveats(std::path::Path::new("/workspace"));
assert!(cav.permits_fs_read("/workspace/src/main.rs"));
assert!(
!cav.permits_fs_write("/workspace/src/main.rs"),
"unconfigured probe leash must not write"
);
assert!(
!cav.permits_exec("cargo"),
"unconfigured probe leash grants no exec (the spawn path widens \
exactly the probed command, nothing else)"
);
}
#[test]
fn mcp_probe_caveats_honors_the_configured_preset() {
let cfg = Config {
tui: Some(TuiConfig {
permissions: ToolPermissions::default(), ..Default::default()
}),
..Default::default()
};
let cav = cfg.mcp_probe_caveats(std::path::Path::new("/ws"));
assert!(cav.permits_exec("cargo"), "configured preset respected");
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![],
net: vec![],
prompt: false,
};
let cav = perms.to_caveats("/workspace");
assert_eq!(cav, crate::caveats::Caveats::top());
}
#[test]
fn net_allowlist_controls_the_net_axis() {
use crate::caveats::Scope;
let none = ToolPermissions::default().to_caveats("/ws");
assert!(
matches!(none.net, Scope::Only(ref s) if s.is_empty()),
"empty net config must yield an empty (deny-all) net scope"
);
let hosts = ToolPermissions {
preset: PermissionPreset::ReadOnly,
extra_exec: vec![],
net: vec!["docs.rs".into(), "github.com".into()],
prompt: false,
}
.to_caveats("/ws");
assert!(
matches!(hosts.net, Scope::Only(ref s) if s.contains("docs.rs") && s.contains("github.com")),
"explicit hosts must populate the net allowlist"
);
let all = ToolPermissions {
preset: PermissionPreset::WorkspaceDev,
extra_exec: vec![],
net: vec!["*".into()],
prompt: false,
}
.to_caveats("/ws");
assert!(
matches!(all.net, Scope::All),
"a `*` entry must grant the whole net axis"
);
}
#[test]
fn custom_is_workspace_dev_not_top() {
let custom = ToolPermissions {
preset: PermissionPreset::Custom,
extra_exec: vec!["bacon".into()],
net: vec![],
prompt: false,
}
.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()],
net: vec![],
prompt: false,
}
.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()],
net: vec![],
prompt: false,
};
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);
}
#[test]
fn with_mcp_enabled_toggles_and_preserves_comments() {
let text = "# my config\n[[mcp_servers]]\nname = \"modulex\"\ncommand = \"modulex-mcp\"\n";
let off = Config::with_mcp_enabled(text, "modulex", false).unwrap();
assert!(off.contains("enabled = false"));
assert!(off.contains("# my config"));
let on = Config::with_mcp_enabled(&off, "modulex", true).unwrap();
assert!(!on.contains("enabled"));
assert!(Config::with_mcp_enabled(text, "nope", false).is_err());
let e: crate::mcp::McpServerEntry =
toml::from_str("name = \"x\"\ncommand = \"x\"\n").unwrap();
assert!(e.enabled);
let d: crate::mcp::McpServerEntry =
toml::from_str("name = \"x\"\ncommand = \"x\"\nenabled = false\n").unwrap();
assert!(!d.enabled);
}
#[test]
fn with_mcp_server_added_appends_and_preserves_comments() {
let text = "\
# hand-authored config
default_backend = \"local\" # keep me
[[mcp_servers]]
name = \"modulex\"
command = \"modulex-mcp\"
";
let entry = crate::mcp::McpServerEntry {
name: "scrybe".into(),
enabled: true,
transport: crate::mcp::TransportKind::Stdio,
command: Some("scrybe-mcp-server".into()),
args: vec!["stdio".into()],
env: std::collections::BTreeMap::from([(
"SCRYBE_LOG".to_string(),
crate::mcp::SecretValue::literal("info"),
)]),
url: None,
headers: std::collections::BTreeMap::new(),
request_timeout_secs: Some(120),
trust: crate::mcp::McpTrust::Trusted,
};
let out = Config::with_mcp_server_added(text, &entry).unwrap();
assert!(
out.contains("# hand-authored config"),
"comment lost: {out}"
);
assert!(out.contains("# keep me"), "inline comment lost: {out}");
assert!(out.contains("modulex-mcp"), "existing entry lost: {out}");
let cfg: Config = toml::from_str(&out).unwrap();
assert_eq!(cfg.mcp_servers.len(), 2);
let added = cfg.mcp_servers.iter().find(|s| s.name == "scrybe").unwrap();
assert_eq!(added.command.as_deref(), Some("scrybe-mcp-server"));
assert_eq!(added.args, vec!["stdio"]);
assert_eq!(
added
.env
.get("SCRYBE_LOG")
.and_then(crate::mcp::SecretValue::as_literal),
Some("info")
);
assert_eq!(added.request_timeout_secs, Some(120));
assert!(added.enabled);
assert!(!out.contains("enabled"), "default enabled written: {out}");
assert!(!out.contains("type"), "default transport written: {out}");
}
#[test]
fn with_mcp_server_added_creates_section_in_empty_text() {
let entry = crate::mcp::McpServerEntry {
name: "fs".into(),
enabled: true,
transport: crate::mcp::TransportKind::Stdio,
command: Some("mcp-fs".into()),
args: vec![],
env: std::collections::BTreeMap::new(),
url: None,
headers: std::collections::BTreeMap::new(),
request_timeout_secs: None,
trust: crate::mcp::McpTrust::Trusted,
};
let out = Config::with_mcp_server_added("", &entry).unwrap();
let cfg: Config = toml::from_str(&out).unwrap();
assert_eq!(cfg.mcp_servers.len(), 1);
assert_eq!(cfg.mcp_servers[0].name, "fs");
assert_eq!(cfg.mcp_servers[0].command.as_deref(), Some("mcp-fs"));
}
#[test]
fn with_mcp_server_added_writes_sse_transport_and_url() {
let entry = crate::mcp::McpServerEntry {
name: "remote".into(),
enabled: true,
transport: crate::mcp::TransportKind::Sse,
command: None,
args: vec![],
env: std::collections::BTreeMap::new(),
url: Some("https://mcp.example/sse".into()),
headers: std::collections::BTreeMap::new(),
request_timeout_secs: None,
trust: crate::mcp::McpTrust::Trusted,
};
let out = Config::with_mcp_server_added("", &entry).unwrap();
let cfg: Config = toml::from_str(&out).unwrap();
assert_eq!(cfg.mcp_servers[0].transport, crate::mcp::TransportKind::Sse);
assert_eq!(
cfg.mcp_servers[0].url.as_deref(),
Some("https://mcp.example/sse")
);
}
#[test]
fn with_mcp_server_added_rejects_duplicates_and_invalid_entries() {
let text = "[[mcp_servers]]\nname = \"scrybe\"\ncommand = \"scrybe-mcp-server\"\n";
let dup = crate::mcp::McpServerEntry {
name: "scrybe".into(),
enabled: true,
transport: crate::mcp::TransportKind::Stdio,
command: Some("other".into()),
args: vec![],
env: std::collections::BTreeMap::new(),
url: None,
headers: std::collections::BTreeMap::new(),
request_timeout_secs: None,
trust: crate::mcp::McpTrust::Trusted,
};
let err = Config::with_mcp_server_added(text, &dup).unwrap_err();
assert!(err.to_string().contains("scrybe"), "names the dup: {err}");
let no_cmd = crate::mcp::McpServerEntry {
name: "ghost".into(),
command: None,
..dup.clone()
};
assert!(Config::with_mcp_server_added("", &no_cmd).is_err());
let no_url = crate::mcp::McpServerEntry {
name: "ghost".into(),
transport: crate::mcp::TransportKind::Http,
command: None,
..dup.clone()
};
assert!(Config::with_mcp_server_added("", &no_url).is_err());
let unnamed = crate::mcp::McpServerEntry {
name: " ".into(),
..dup.clone()
};
assert!(Config::with_mcp_server_added("", &unnamed).is_err());
}
#[test]
fn with_mcp_server_removed_deletes_only_the_named_entry() {
let text = "\
# my config
[[mcp_servers]]
name = \"keep\"
command = \"keep-mcp\" # keep note
[[mcp_servers]]
name = \"drop\"
command = \"drop-mcp\"
";
let out = Config::with_mcp_server_removed(text, "drop").unwrap();
assert!(out.contains("# my config"), "comment lost: {out}");
assert!(out.contains("# keep note"), "inline comment lost: {out}");
let cfg: Config = toml::from_str(&out).unwrap();
assert_eq!(cfg.mcp_servers.len(), 1);
assert_eq!(cfg.mcp_servers[0].name, "keep");
assert!(!out.contains("drop-mcp"));
}
#[test]
fn with_mcp_server_removed_reports_a_non_array_section_accurately() {
let text = "mcp_servers = [ { name = \"x\", command = \"y\" } ]\n";
let err = Config::with_mcp_server_removed(text, "x").unwrap_err();
assert!(
err.to_string().contains("not an array of tables"),
"wrong-shape section misreported: {err}"
);
let err = Config::with_mcp_server_removed("mcp_servers = 3\n", "x").unwrap_err();
assert!(
err.to_string().contains("not an array of tables"),
"scalar section misreported: {err}"
);
}
#[test]
fn mcp_writer_error_branches_are_loud() {
let entry = crate::mcp::McpServerEntry {
name: "x".into(),
enabled: true,
transport: crate::mcp::TransportKind::Stdio,
command: Some("x-mcp".into()),
args: vec![],
env: std::collections::BTreeMap::new(),
url: None,
headers: std::collections::BTreeMap::new(),
request_timeout_secs: None,
trust: crate::mcp::McpTrust::Trusted,
};
let err = Config::with_mcp_server_added("not toml [", &entry).unwrap_err();
assert!(err.to_string().contains("not valid TOML"), "{err}");
let err = Config::with_mcp_server_removed("not toml [", "x").unwrap_err();
assert!(err.to_string().contains("not valid TOML"), "{err}");
let err = Config::with_mcp_server_added("mcp_servers = 3\n", &entry).unwrap_err();
assert!(err.to_string().contains("not an array of tables"), "{err}");
let oversized = crate::mcp::McpServerEntry {
request_timeout_secs: Some(u64::MAX),
..entry
};
let err = Config::with_mcp_server_added("", &oversized).unwrap_err();
assert!(err.to_string().contains("out of range"), "{err}");
}
#[test]
fn with_mcp_server_removed_errors_when_absent() {
let text = "[[mcp_servers]]\nname = \"present\"\ncommand = \"x\"\n";
let err = Config::with_mcp_server_removed(text, "ghost").unwrap_err();
assert!(err.to_string().contains("ghost"), "names the miss: {err}");
assert!(Config::with_mcp_server_removed("", "ghost").is_err());
}
#[test]
fn with_default_backend_updates_value_and_preserves_unrelated_content() {
let original = "\
# hand-authored config
default_backend = \"old\" # keep this selection note
[discovery]
hosts = [\"localhost\", \"dgx1.home.lab\"]
[custom]
operator_note = \"leave me alone\" # custom inline comment
";
let out = Config::with_default_backend(original, "dgx1-openai-8000").unwrap();
let parsed: toml::Value = toml::from_str(&out).unwrap();
assert_eq!(
parsed.get("default_backend").and_then(toml::Value::as_str),
Some("dgx1-openai-8000")
);
assert!(
out.contains("# hand-authored config"),
"top comment lost: {out}"
);
assert!(
out.contains("# keep this selection note"),
"target inline comment lost: {out}"
);
assert!(
out.contains("dgx1.home.lab"),
"discovery table changed: {out}"
);
assert!(
out.contains("leave me alone"),
"custom table changed: {out}"
);
assert!(
out.contains("# custom inline comment"),
"unrelated inline comment lost: {out}"
);
}
#[test]
fn with_default_backend_creates_key_and_is_idempotent() {
let original = "# config without a default\n[discovery]\nhosts = [\"localhost\"]\n";
let once = Config::with_default_backend(original, "local").unwrap();
let twice = Config::with_default_backend(&once, "local").unwrap();
let parsed: toml::Value = toml::from_str(&once).unwrap();
assert_eq!(
parsed.get("default_backend").and_then(toml::Value::as_str),
Some("local")
);
assert_eq!(twice, once, "reapplying the same default changed output");
assert_eq!(twice.matches("default_backend").count(), 1);
}
#[test]
fn with_default_backend_rejects_empty_name() {
assert!(Config::with_default_backend("", "").is_err());
assert!(Config::with_default_backend("", " ").is_err());
}
#[test]
fn with_default_backend_rejects_invalid_toml() {
assert!(Config::with_default_backend("this = = not toml", "local").is_err());
}
#[test]
fn with_net_host_creates_table_from_empty_and_scope_includes_host() {
let out = Config::with_net_host("", "github.com").unwrap();
let cfg: Config = toml::from_str(&out).unwrap();
let perms = cfg.tui.unwrap().permissions;
assert!(perms.net.contains(&"github.com".to_string()));
assert!(
matches!(perms.net_scope(), crate::caveats::Scope::Only(ref s) if s.contains("github.com")),
"net_scope must permit the granted host"
);
}
#[test]
fn with_net_host_preserves_comments_and_other_keys() {
let original = "\
# my hand-authored config — keep this comment
[tui.permissions]
preset = \"workspace_dev\" # inline comment
net = [\"already.example.com\"]
";
let out = Config::with_net_host(original, "github.com").unwrap();
assert!(
out.contains("# my hand-authored config"),
"top comment lost: {out}"
);
assert!(
out.contains("# inline comment"),
"inline comment lost: {out}"
);
assert!(out.contains("already.example.com"));
assert!(out.contains("github.com"));
assert!(out.contains("workspace_dev"));
}
#[test]
fn with_net_host_is_idempotent_no_duplicate() {
let once = Config::with_net_host("", "github.com").unwrap();
let twice = Config::with_net_host(&once, "github.com").unwrap();
assert_eq!(
twice.matches("github.com").count(),
1,
"duplicated host: {twice}"
);
}
#[test]
fn with_net_host_rejects_invalid_toml() {
assert!(Config::with_net_host("this = = not toml", "github.com").is_err());
}
fn openai_backend(api_key_file: Option<String>, api_key_env: Option<String>) -> BackendConfig {
BackendConfig {
name: "remote".into(),
endpoint: "https://example.test".into(),
model: Some("some-model".into()),
model_path: None,
tiers: vec![Tier::Fast],
kind: Some(BackendKind::Openai),
api: Default::default(),
api_key_file,
api_key_env,
..Default::default()
}
}
#[test]
fn backend_kind_absent_means_probe_at_connect() {
let toml = r#"
[[backends]]
name = "local"
endpoint = "http://localhost:8000"
model = "m"
tiers = ["FAST"]
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.backends[0].kind, None);
assert!(cfg.backends[0].needs_kind_probe());
assert_eq!(cfg.backends[0].kind_label(), "auto");
assert!(cfg.backends[0].api_key_file.is_none());
assert!(cfg.backends[0].api_key_env.is_none());
}
#[test]
fn backend_kind_parses_openai_and_aliases() {
for kind_str in ["openai", "vllm", "openai-compatible"] {
let toml = format!(
"[[backends]]\nname=\"x\"\nendpoint=\"http://e\"\nmodel=\"m\"\ntiers=[\"FAST\"]\nkind=\"{kind_str}\"\n"
);
let cfg: Config = toml::from_str(&toml).unwrap();
assert_eq!(
cfg.backends[0].kind,
Some(BackendKind::Openai),
"kind={kind_str}"
);
}
}
#[test]
fn backend_kind_label_is_protocol_name() {
assert_eq!(BackendKind::Ollama.label(), "ollama");
assert_eq!(BackendKind::Openai.label(), "openai");
}
#[test]
fn backend_config_roundtrips_auth_fields() {
let cfg = openai_backend(Some("~/.newt/token".into()), Some("MY_TOKEN".into()));
let toml = toml::to_string(&cfg).unwrap();
assert!(toml.contains("kind = \"openai\""));
assert!(toml.contains("api_key_file"));
assert!(toml.contains("api_key_env"));
let back: BackendConfig = toml::from_str(&toml).unwrap();
assert_eq!(back.kind, Some(BackendKind::Openai));
assert_eq!(back.api_key_file.as_deref(), Some("~/.newt/token"));
assert_eq!(back.api_key_env.as_deref(), Some("MY_TOKEN"));
}
#[test]
fn resolve_api_key_reads_first_nonempty_line_of_file() {
let mut f = tempfile::NamedTempFile::new().unwrap();
write!(f, "\n secret-token-123 \nignored-second-line\n").unwrap();
let cfg = openai_backend(Some(f.path().to_string_lossy().into_owned()), None);
assert_eq!(cfg.resolve_api_key().as_deref(), Some("secret-token-123"));
}
#[test]
fn resolve_api_key_env_takes_precedence_over_file() {
let var = "NEWT_TEST_API_KEY_PRECEDENCE";
std::env::set_var(var, " from-env ");
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "from-file").unwrap();
let cfg = openai_backend(
Some(f.path().to_string_lossy().into_owned()),
Some(var.into()),
);
assert_eq!(cfg.resolve_api_key().as_deref(), Some("from-env"));
std::env::remove_var(var);
}
#[test]
fn resolve_api_key_none_when_unconfigured() {
assert_eq!(openai_backend(None, None).resolve_api_key(), None);
}
#[test]
fn resolve_api_key_none_for_missing_file() {
let cfg = openai_backend(Some("/no/such/newt/token/file".into()), None);
assert_eq!(cfg.resolve_api_key(), None);
}
#[test]
fn expand_tilde_expands_home_and_passes_through() {
let home = home_dir().expect("HOME set in test env");
assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
assert_eq!(expand_tilde("~"), home);
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
assert_eq!(
expand_tilde("relative/path"),
PathBuf::from("relative/path")
);
}
#[test]
fn spill_lines_defaults_to_3_and_overrides() {
assert_eq!(TuiConfig::default().spill_lines, 3);
let empty: TuiConfig = toml::from_str("").unwrap();
assert_eq!(empty.spill_lines, 3);
let set: TuiConfig = toml::from_str("spill_lines = 7").unwrap();
assert_eq!(set.spill_lines, 7);
}
#[test]
fn default_max_tool_rounds_is_40() {
assert_eq!(default_max_tool_rounds(), 40);
assert_eq!(TuiConfig::default().max_tool_rounds, 40);
assert_eq!(default_workflow_grace_rounds(), 5);
assert_eq!(TuiConfig::default().workflow_grace_rounds, 5);
}
#[test]
fn tui_max_tool_rounds_defaults_when_field_absent() {
let toml = r#"
[tui]
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.tui.unwrap().max_tool_rounds, 40);
}
#[test]
fn tui_max_tool_rounds_can_be_overridden() {
let toml = r#"
[tui]
max_tool_rounds = 7
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.tui.unwrap().max_tool_rounds, 7);
}
#[test]
fn tui_narration_nudge_cap_defaults_to_one_and_can_be_raised() {
assert_eq!(default_narration_nudge_cap(), 1);
assert_eq!(TuiConfig::default().narration_nudge_cap, 1);
let cfg: Config = toml::from_str("[tui]\n").unwrap();
assert_eq!(cfg.tui.unwrap().narration_nudge_cap, 1);
let cfg: Config = toml::from_str("[tui]\nnarration_nudge_cap = 3\n").unwrap();
assert_eq!(cfg.tui.unwrap().narration_nudge_cap, 3);
}
#[test]
fn model_tuning_narration_nudge_cap_override_parses() {
let cfg: Config = toml::from_str(
r#"
[[model_tuning]]
model = "ornith:35b"
narration_nudge_cap = 3
"#,
)
.unwrap();
let tune = cfg.find_model_tuning("ornith:35b").unwrap();
assert_eq!(tune.narration_nudge_cap, Some(3));
let cfg: Config = toml::from_str(
r#"
[[model_tuning]]
model = "other:7b"
max_tool_rounds = 9
"#,
)
.unwrap();
assert_eq!(
cfg.find_model_tuning("other:7b")
.unwrap()
.narration_nudge_cap,
None
);
}
#[test]
fn tui_workflow_grace_rounds_can_be_overridden_or_disabled() {
let cfg: Config = toml::from_str(
r#"
[tui]
workflow_grace_rounds = 9
"#,
)
.unwrap();
assert_eq!(cfg.tui.unwrap().workflow_grace_rounds, 9);
let disabled: Config = toml::from_str(
r#"
[tui]
workflow_grace_rounds = 0
"#,
)
.unwrap();
assert_eq!(disabled.tui.unwrap().workflow_grace_rounds, 0);
}
#[test]
fn model_tuning_parses_from_toml() {
let toml = r#"
[[model_tuning]]
model = "nemotron3:33b"
num_ctx = 24576
mid_loop_trim_threshold = 12
max_tool_rounds = 20
workflow_grace_rounds = 8
[[model_tuning]]
model = "qwen3-coder:30b"
num_ctx = 65536
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.model_tuning.len(), 2);
let nemo = cfg.find_model_tuning("nemotron3:33b").unwrap();
assert_eq!(nemo.num_ctx, Some(24576));
assert_eq!(nemo.mid_loop_trim_threshold, Some(12));
assert_eq!(nemo.max_tool_rounds, Some(20));
assert_eq!(nemo.workflow_grace_rounds, Some(8));
let qwen = cfg.find_model_tuning("qwen3-coder:30b").unwrap();
assert_eq!(qwen.num_ctx, Some(65536));
assert_eq!(qwen.mid_loop_trim_threshold, None);
assert_eq!(qwen.workflow_grace_rounds, None);
}
#[test]
fn model_tuning_find_returns_none_for_unknown_model() {
let cfg = Config::default();
assert!(cfg.find_model_tuning("nonexistent:7b").is_none());
}
#[test]
fn model_tuning_partial_fields_are_optional() {
let toml = r#"
[[model_tuning]]
model = "llama3.1:8b"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
let entry = cfg.find_model_tuning("llama3.1:8b").unwrap();
assert_eq!(entry.num_ctx, None);
assert_eq!(entry.mid_loop_trim_threshold, None);
assert_eq!(entry.max_tool_rounds, None);
assert_eq!(entry.workflow_grace_rounds, None);
}
#[test]
fn tools_max_output_tokens_defaults_to_10k_when_absent() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.tools.is_none());
assert_eq!(cfg.max_output_tokens(), 10_000);
assert_eq!(cfg.output_head_tokens(), 1_500);
assert_eq!(Config::default().max_output_tokens(), 10_000);
assert_eq!(Config::default().output_head_tokens(), 1_500);
}
#[test]
fn tools_output_cap_chars_per_token_defaults_to_3_and_parses_an_override() {
let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.output_cap_chars_per_token(), 3);
assert_eq!(Config::default().output_cap_chars_per_token(), 3);
let cfg: Config = toml::from_str("[tools]\n").unwrap();
assert_eq!(cfg.output_cap_chars_per_token(), 3);
let cfg: Config = toml::from_str("[tools]\noutput_cap_chars_per_token = 2\n").unwrap();
assert_eq!(cfg.tools.as_ref().unwrap().output_cap_chars_per_token, 2);
assert_eq!(cfg.output_cap_chars_per_token(), 2);
}
#[test]
fn tools_max_output_tokens_parses_an_override() {
let cfg: Config = toml::from_str(
r#"
[tools]
max_output_tokens = 4096
output_head_tokens = 512
"#,
)
.unwrap();
assert_eq!(cfg.tools.as_ref().unwrap().max_output_tokens, 4096);
assert_eq!(cfg.tools.as_ref().unwrap().output_head_tokens, 512);
assert_eq!(cfg.max_output_tokens(), 4096);
assert_eq!(cfg.output_head_tokens(), 512);
}
#[test]
fn tools_config_default_field_is_the_shared_default() {
let cfg: Config = toml::from_str("[tools]\n").unwrap();
assert_eq!(cfg.max_output_tokens(), 10_000);
assert_eq!(cfg.output_head_tokens(), 1_500);
}
#[test]
fn tools_max_output_tokens_zero_is_a_valid_no_cap() {
let cfg: Config = toml::from_str("[tools]\nmax_output_tokens = 0\n").unwrap();
assert_eq!(cfg.max_output_tokens(), 0);
}
#[test]
fn tool_exposure_defaults_to_full_identity_when_absent() {
let cfg: Config = toml::from_str("").unwrap();
assert!(cfg.tool_exposure.is_none());
let resolved = cfg.tool_exposure();
assert_eq!(resolved.profile, ExposureProfile::Full);
assert_eq!(resolved.schema_budget_pct, 15);
assert_eq!(resolved.max_initial_tools, 0);
assert!(resolved.supports_dynamic_catalog);
assert_eq!(
Config::default().tool_exposure().profile,
ExposureProfile::Full
);
}
#[test]
fn tool_exposure_parses_an_auto_profile_override() {
let cfg: Config = toml::from_str(
r#"
[tool_exposure]
profile = "auto"
schema_budget_pct = 12
max_initial_tools = 8
supports_dynamic_catalog = false
"#,
)
.unwrap();
let resolved = cfg.tool_exposure();
assert_eq!(resolved.profile, ExposureProfile::Auto);
assert_eq!(resolved.schema_budget_pct, 12);
assert_eq!(resolved.max_initial_tools, 8);
assert!(!resolved.supports_dynamic_catalog);
}
#[test]
fn tool_exposure_minimal_profile_parses() {
let cfg: Config = toml::from_str("[tool_exposure]\nprofile = \"minimal\"\n").unwrap();
let resolved = cfg.tool_exposure();
assert_eq!(resolved.profile, ExposureProfile::Minimal);
assert_eq!(resolved.schema_budget_pct, 15);
}
}