use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{NewtError, Result};
use crate::router::Tier;
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>,
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 context: Option<ContextConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsConfig>,
#[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 {
true
}
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>,
}
#[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)]
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();
base.tool_offload = true;
if matches!(kind, BackendKind::Ollama) {
base.scratchpad = true;
base.semantic = true;
base.scheduled = 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 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,
}
fn default_summary_input_cap_floor_chars() -> usize {
8_192
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
manager: ContextManager::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(),
}
}
}
#[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,
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 = "default_api_surface_max_block_chars")]
pub max_block_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: default_api_surface_max_block_chars(),
max_symbols_per_file: default_api_surface_max_symbols_per_file(),
}
}
}
fn default_api_surface_max_block_chars() -> usize {
3_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 ToolsConfig {
#[serde(default = "default_max_output_tokens")]
pub max_output_tokens: usize,
#[serde(default = "default_output_head_tokens")]
pub output_head_tokens: usize,
}
impl Default for ToolsConfig {
fn default() -> Self {
Self {
max_output_tokens: default_max_output_tokens(),
output_head_tokens: default_output_head_tokens(),
}
}
}
fn default_max_output_tokens() -> usize {
10_000
}
fn default_output_head_tokens() -> usize {
1_500
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiConfig {
pub chat_style: ChatStyle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default)]
pub no_splash: bool,
#[serde(default)]
pub edit_mode: EditMode,
#[serde(default, 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_max_tool_rounds")]
pub max_tool_rounds: usize,
#[serde(default = "default_workflow_grace_rounds")]
pub workflow_grace_rounds: 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,
}
fn default_tool_output_lines() -> usize {
20
}
fn default_max_tool_rounds() -> usize {
40
}
fn default_workflow_grace_rounds() -> usize {
5
}
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
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelTuning {
pub model: 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, skip_serializing_if = "Option::is_none")]
pub mid_loop_trim_threshold: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mid_loop_trim_tokens: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_rounds: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_grace_rounds: Option<usize>,
}
impl Config {
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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEngine {
#[default]
SafeSubset,
Host,
Brush,
}
impl ShellEngine {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::SafeSubset => "safe-subset",
Self::Host => "host",
Self::Brush => "brush",
}
}
}
impl std::fmt::Display for ShellEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ShellEngine {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"safe-subset" | "subset" | "safe" => Ok(Self::SafeSubset),
"host" | "sandbox-host" | "landlock" | "seatbelt" => Ok(Self::Host),
"brush" | "brush-ocap" => Ok(Self::Brush),
other => Err(format!(
"unknown shell engine '{other}' (expected one of: safe-subset, host, brush)"
)),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ShellConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine: Option<ShellEngine>,
}
#[must_use]
pub fn full_access_default_engine() -> ShellEngine {
#[cfg(windows)]
{
ShellEngine::Brush
}
#[cfg(not(windows))]
{
ShellEngine::Host
}
}
#[must_use]
pub fn resolve_shell_engine(
cli: Option<ShellEngine>,
configured: Option<ShellEngine>,
full_access: bool,
) -> ShellEngine {
cli.or(configured).unwrap_or(if full_access {
full_access_default_engine()
} else {
ShellEngine::SafeSubset
})
}
#[must_use]
pub fn ocap_l3_backend() -> (&'static str, bool) {
#[cfg(target_os = "linux")]
{
("Landlock", agent_bridle::landlock_is_supported())
}
#[cfg(target_os = "macos")]
{
("Seatbelt", agent_bridle::seatbelt_is_supported())
}
#[cfg(target_os = "windows")]
{
("AppContainer", true)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
("none", false)
}
}
#[cfg(test)]
mod shell_engine_tests {
use super::{full_access_default_engine, resolve_shell_engine, ShellConfig, ShellEngine};
#[test]
fn from_str_canonical_and_aliases() {
assert_eq!(
"safe-subset".parse::<ShellEngine>().unwrap(),
ShellEngine::SafeSubset
);
assert_eq!(
"subset".parse::<ShellEngine>().unwrap(),
ShellEngine::SafeSubset
);
assert_eq!("host".parse::<ShellEngine>().unwrap(), ShellEngine::Host);
assert_eq!(
"landlock".parse::<ShellEngine>().unwrap(),
ShellEngine::Host
);
assert_eq!("BRUSH".parse::<ShellEngine>().unwrap(), ShellEngine::Brush);
assert_eq!(
"brush-ocap".parse::<ShellEngine>().unwrap(),
ShellEngine::Brush
);
assert!("bogus".parse::<ShellEngine>().is_err());
}
#[test]
fn as_str_roundtrips() {
for e in [
ShellEngine::SafeSubset,
ShellEngine::Host,
ShellEngine::Brush,
] {
assert_eq!(e.as_str().parse::<ShellEngine>().unwrap(), e);
}
}
#[test]
fn resolve_flag_wins_over_everything() {
assert_eq!(
resolve_shell_engine(
Some(ShellEngine::SafeSubset),
Some(ShellEngine::Brush),
true
),
ShellEngine::SafeSubset,
"explicit --shell-engine wins even over config and --full-access"
);
}
#[test]
fn resolve_config_wins_over_full_access_auto_upgrade() {
assert_eq!(
resolve_shell_engine(None, Some(ShellEngine::SafeSubset), true),
ShellEngine::SafeSubset,
"an explicit [shell] engine is not overridden by --full-access"
);
}
#[test]
fn resolve_full_access_auto_upgrades_when_unset() {
assert_eq!(
resolve_shell_engine(None, None, true),
full_access_default_engine()
);
#[cfg(not(windows))]
assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Host);
#[cfg(windows)]
assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Brush);
}
#[test]
fn resolve_defaults_to_safe_subset() {
assert_eq!(
resolve_shell_engine(None, None, false),
ShellEngine::SafeSubset
);
}
#[test]
fn shell_config_deserializes_kebab_case() {
let cfg: ShellConfig = toml::from_str("engine = \"host\"").unwrap();
assert_eq!(cfg.engine, Some(ShellEngine::Host));
let cfg: ShellConfig = toml::from_str("engine = \"safe-subset\"").unwrap();
assert_eq!(cfg.engine, Some(ShellEngine::SafeSubset));
}
}
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,
edit_mode: EditMode::Nano,
gutter: None,
footer: FooterMode::Auto,
color: ColorMode::Auto,
thinking: ThinkingMode::Stream,
tool_output_lines: default_tool_output_lines(),
max_tool_rounds: default_max_tool_rounds(),
workflow_grace_rounds: default_workflow_grace_rounds(),
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(),
}
}
}
#[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,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendConfig {
#[serde(default)]
pub name: String,
#[serde(default)]
pub endpoint: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_path: Option<String>,
#[serde(default)]
pub tiers: Vec<Tier>,
#[serde(default)]
pub kind: BackendKind,
#[serde(default)]
pub api: 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>,
}
impl BackendConfig {
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, 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>,
}
fn fallback_localhost_backend() -> BackendConfig {
BackendConfig {
name: "ollama".into(),
endpoint: "http://127.0.0.1:11434".into(),
model: "llama3.1:8b".into(),
model_path: None,
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
kind: BackendKind::Ollama,
api: Default::default(),
api_key_file: None,
api_key_env: None,
}
}
impl Default for Config {
fn default() -> Self {
Self {
backends: vec![fallback_localhost_backend()],
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,
context: None,
tools: 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 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);
merge_toml(&mut merged, project_val, strategy);
merged
.try_into()
.map_err(|e| NewtError::Config(e.to_string()))?
}
};
cfg.merge_disk_backends();
if cfg.backends.is_empty() {
cfg.backends.push(fallback_localhost_backend());
}
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());
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 output_head_tokens(&self) -> usize {
self.tools
.as_ref()
.map(|t| t.output_head_tokens)
.unwrap_or_else(default_output_head_tokens)
}
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) => *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> {
let mut redacted = self.clone();
for server in &mut redacted.mcp_servers {
for v in server.env.values_mut() {
*v = Self::REDACTED.to_string();
}
for v in server.headers.values_mut() {
*v = Self::REDACTED.to_string();
}
}
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(&[]);
if configured.is_empty() {
let default = Self::user_config_dir()
.map(|dir| dir.join("skills"))
.unwrap_or_else(|| PathBuf::from(".newt/skills"));
return vec![default];
}
configured.iter().map(|s| expand_tilde(s)).collect()
}
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)
}
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
}
}
pub(crate) fn home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.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 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)
}
#[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 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\"").unwrap();
assert_eq!(c.manager, ContextManager::Progressive);
assert_eq!(ContextConfig::default().manager, ContextManager::Standard);
}
#[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 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, OpenAiApi::ChatCompletions);
let resp: BackendConfig = toml::from_str(
"endpoint=\"http://h:1\"\nmodel=\"gpt-5-codex\"\nkind=\"openai\"\napi=\"responses\"\n",
)
.unwrap();
assert_eq!(resp.api, OpenAiApi::Responses);
let alias: BackendConfig =
toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\napi=\"chat\"\n").unwrap();
assert_eq!(alias.api, OpenAiApi::ChatCompletions);
}
#[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: "old-model".into(),
model_path: None,
tiers: vec![],
kind: BackendKind::Ollama,
api: Default::default(),
api_key_file: None,
api_key_env: None,
},
BackendConfig {
name: "gnuc".into(),
endpoint: "http://gnuc:11434".into(),
model: "qwen2.5-coder:14b".into(),
model_path: None,
tiers: vec![],
kind: BackendKind::Ollama,
api: Default::default(),
api_key_file: None,
api_key_env: None,
},
],
..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.model, "qwen3:30b");
assert_eq!(dgx1.kind, BackendKind::Ollama, "kind defaults to ollama");
assert!(cfg.backends.iter().any(|b| b.name == "gnuc"), "gnuc kept");
}
#[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 skill_search_dirs_preserves_configured_order() {
let cfg = Config {
skills: Some(SkillsConfig {
search: vec!["/abs/one".into(), "/abs/two".into()],
}),
..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()],
}),
..Config::default()
};
let dirs = cfg.skill_search_dirs();
assert!(dirs[0].ends_with("skills-x"));
assert!(!dirs[0].starts_with("~"));
}
#[test]
fn skills_search_round_trips_through_toml() {
let cfg = Config {
skills: Some(SkillsConfig {
search: vec!["~/.newt/skills".into(), "~/.claude/skills".into()],
}),
..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_off_switch_parses() {
let cfg: Config = toml::from_str(
r#"
[conversations]
resume = false
"#,
)
.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].model, "mistral:7b");
assert_eq!(cfg.backends[0].tiers, vec![Tier::Fast, Tier::Standard]);
assert_eq!(cfg.providers.len(), 1);
assert_eq!(cfg.providers[0].name, "cloud");
assert_eq!(cfg.providers[0].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"]);
}
#[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 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 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_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-model".into(),
model_path: None,
tiers: vec![Tier::Fast],
kind: BackendKind::Openai,
api: Default::default(),
api_key_file,
api_key_env,
}
}
#[test]
fn backend_kind_defaults_to_ollama_when_absent() {
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, BackendKind::Ollama);
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, 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, 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 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_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_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);
}
}