use crate::core::shell_config::ShellConfig;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::OnceLock;
static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
static IDENTITY: OnceLock<String> = OnceLock::new();
pub const DEFAULT_IDENTITY: &str =
"You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
pub fn get_identity() -> String {
IDENTITY
.get()
.cloned()
.unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
}
pub fn get_provider_keys() -> BTreeMap<String, String> {
PROVIDER_KEYS.get().cloned().unwrap_or_default()
}
pub fn get_profile() -> Option<String> {
PROFILE_NAME
.get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
.clone()
}
pub fn set_profile(name: Option<String>) {
let _ = PROFILE_NAME.set(name);
}
pub fn base_dir() -> PathBuf {
if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
return PathBuf::from(path);
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".synaps-cli")
}
#[doc(hidden)]
pub fn set_base_dir_for_tests(path: PathBuf) {
std::env::set_var("SYNAPS_BASE_DIR", path);
}
pub fn resolve_read_path(filename: &str) -> PathBuf {
let base = base_dir();
if let Some(profile) = get_profile() {
let profile_path = base.join(&profile).join(filename);
if profile_path.exists() {
return profile_path;
}
}
base.join(filename)
}
pub fn resolve_read_path_extended(path: &str) -> PathBuf {
let base = base_dir();
if let Some(profile) = get_profile() {
let profile_path = base.join(&profile).join(path);
if profile_path.exists() {
return profile_path;
}
}
base.join(path)
}
pub fn resolve_write_path(filename: &str) -> PathBuf {
let mut base = base_dir();
if let Some(profile) = get_profile() {
base.push(profile);
}
let _ = std::fs::create_dir_all(&base);
base.join(filename)
}
pub fn get_active_config_dir() -> PathBuf {
let mut base = base_dir();
if let Some(profile) = get_profile() {
base.push(profile);
}
base
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ContextManagementMode {
#[default]
Off,
Auto,
}
impl ContextManagementMode {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"off" => Some(Self::Off),
"auto" => Some(Self::Auto),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ContextManagementConfig {
pub mode: ContextManagementMode,
pub pressure_tokens: Option<u64>,
pub rollover_tokens: Option<u64>,
pub reserve_tokens: u64,
pub finish_rounds: u32,
}
impl Default for ContextManagementConfig {
fn default() -> Self {
Self {
mode: ContextManagementMode::Off,
pressure_tokens: None,
rollover_tokens: None,
reserve_tokens: 16_000,
finish_rounds: 2,
}
}
}
impl ContextManagementConfig {
pub const MAX_FINISH_ROUNDS: u32 = 8;
pub const MAX_RESERVE_TOKENS: u64 = 1_000_000;
pub fn validate(&self) -> Result<(), &'static str> {
if self.pressure_tokens == Some(0) {
return Err("pressure_tokens must be positive");
}
if self.rollover_tokens.is_some_and(|v| v < 2) {
return Err("rollover_tokens must be at least 2");
}
if let (Some(pressure), Some(rollover)) = (self.pressure_tokens, self.rollover_tokens) {
if pressure >= rollover {
return Err("pressure_tokens must be below rollover_tokens");
}
}
if !(1..=Self::MAX_RESERVE_TOKENS).contains(&self.reserve_tokens) {
return Err("reserve_tokens must be between 1 and 1000000");
}
if self.finish_rounds > Self::MAX_FINISH_ROUNDS {
return Err("finish_rounds must be between 0 and 8");
}
Ok(())
}
pub fn validate_for_window(&self, context_window_tokens: u64) -> Result<(), &'static str> {
self.validate()?;
if context_window_tokens < 2 {
return Err("context window must contain at least two tokens");
}
if self
.pressure_tokens
.is_some_and(|v| v >= context_window_tokens)
{
return Err("pressure_tokens must be below the context window");
}
if self
.rollover_tokens
.is_some_and(|v| v > context_window_tokens)
{
return Err("rollover_tokens must not exceed the context window");
}
Ok(())
}
}
fn parse_context_management_config_key(
config: &mut ContextManagementConfig,
key: &str,
value: &str,
) -> Result<(), &'static str> {
match key {
"context_management.mode" => {
config.mode = ContextManagementMode::parse(value).ok_or("expected off or auto")?;
}
"context_management.pressure_tokens" | "context_management.rollover_tokens" => {
let tokens = if value.eq_ignore_ascii_case("auto") {
None
} else {
Some(
value
.parse::<u64>()
.map_err(|_| "expected auto or an unsigned token count")?,
)
};
if key == "context_management.pressure_tokens" {
config.pressure_tokens = tokens;
} else {
config.rollover_tokens = tokens;
}
}
"context_management.reserve_tokens" => {
config.reserve_tokens = value
.parse()
.map_err(|_| "expected an unsigned token count")?;
}
"context_management.finish_rounds" => {
config.finish_rounds = value
.parse()
.map_err(|_| "expected an unsigned round count")?;
}
_ => return Err("unknown context management key"),
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StartupConfig {
pub quick_start: bool,
pub extensions_ready_timeout_secs: u64,
}
impl Default for StartupConfig {
fn default() -> Self {
Self {
quick_start: true,
extensions_ready_timeout_secs: 30,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
pub idle_exit_secs: u64,
pub prompt_abandon_secs: u64,
pub parked_evict_secs: u64,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
idle_exit_secs: 10,
prompt_abandon_secs: 3600,
parked_evict_secs: 3600,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubagentLimits {
pub max_concurrent: usize,
pub max_total: usize,
pub max_depth: u16,
}
impl Default for SubagentLimits {
fn default() -> Self {
Self {
max_concurrent: 16,
max_total: 64,
max_depth: 4,
}
}
}
fn parse_subagent_config_key(
config: &mut SubagentLimits,
key: &str,
value: &str,
) -> Result<(), &'static str> {
let positive = || match value.trim().parse::<usize>() {
Ok(n) if n > 0 => Ok(n),
_ => Err("expected a whole number of at least 1"),
};
match key {
"subagent.max_concurrent" => config.max_concurrent = positive()?,
"subagent.max_total" => config.max_total = positive()?,
"subagent.max_depth" => {
config.max_depth =
u16::try_from(positive()?).map_err(|_| "expected a whole number from 1 to 65535")?
}
_ => return Err("unknown subagent key"),
}
Ok(())
}
fn parse_startup_config_key(
config: &mut StartupConfig,
key: &str,
value: &str,
) -> Result<(), &'static str> {
match key {
"startup.quick_start" => {
config.quick_start = match value.trim() {
"true" | "1" | "on" | "yes" => true,
"false" | "0" | "off" | "no" => false,
_ => return Err("expected on or off"),
};
}
"startup.extensions_ready_timeout_secs" => {
config.extensions_ready_timeout_secs =
value.trim().parse().map_err(|_| "expected an unsigned second count")?;
}
_ => return Err("unknown startup key"),
}
Ok(())
}
fn parse_daemon_config_key(
config: &mut DaemonConfig,
key: &str,
value: &str,
) -> Result<(), &'static str> {
let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
match key {
"daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
"daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
"daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
_ => return Err("unknown daemon key"),
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct ServerConfig {
pub allowed_origins: Vec<String>,
pub token: Option<String>,
pub auto_approve_confirms: bool,
pub max_message_size: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct BridgeConfig {
pub uds_path: Option<PathBuf>,
pub heartbeat_mirror: bool,
pub heartbeat_timeout_ms: u64,
}
impl Default for BridgeConfig {
fn default() -> Self {
Self {
uds_path: None,
heartbeat_mirror: false,
heartbeat_timeout_ms: 250,
}
}
}
impl BridgeConfig {
pub fn resolved_uds_path(&self) -> PathBuf {
self.uds_path
.clone()
.unwrap_or_else(|| base_dir().join("bridge/control.sock"))
}
}
#[derive(Debug, Clone, Default)]
pub struct AuthConfig {
pub remote_endpoint: Option<String>,
pub machine_token: Option<String>,
}
impl AuthConfig {
pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| self.remote_endpoint.clone());
let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| self.machine_token.clone());
crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
}
}
pub const MAX_CACHE_KEEPWARM_HOURS: u32 = 48;
pub const DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS: u32 = 24;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CacheKeepWarm {
#[default]
Off,
Auto,
Hours(u32),
}
impl CacheKeepWarm {
pub fn parse(val: &str) -> Option<CacheKeepWarm> {
let v = val.trim().to_ascii_lowercase();
match v.as_str() {
"off" | "false" | "no" | "0" => return Some(CacheKeepWarm::Off),
"auto" | "on" => return Some(CacheKeepWarm::Auto),
_ => {}
}
let digits = v.strip_suffix('h')?;
match digits.parse::<u32>().ok()? {
0 => Some(CacheKeepWarm::Off),
h if h <= MAX_CACHE_KEEPWARM_HOURS => Some(CacheKeepWarm::Hours(h)),
_ => None,
}
}
pub fn is_off(self) -> bool {
self == CacheKeepWarm::Off
}
}
impl std::fmt::Display for CacheKeepWarm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CacheKeepWarm::Off => f.write_str("off"),
CacheKeepWarm::Auto => f.write_str("auto"),
CacheKeepWarm::Hours(h) => write!(f, "{h}h"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CacheTtl {
#[default]
FiveMinutes,
OneHour,
Hybrid,
}
impl CacheTtl {
pub fn parse(val: &str) -> Option<CacheTtl> {
match val.to_ascii_lowercase().as_str() {
"5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
"1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
"hybrid" => Some(CacheTtl::Hybrid),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ActivationConfirm {
#[default]
Auto,
Prompt,
Deny,
}
impl ActivationConfirm {
pub fn parse(val: &str) -> Option<Self> {
match val.trim().to_ascii_lowercase().as_str() {
"auto" => Some(Self::Auto),
"prompt" => Some(Self::Prompt),
"deny" => Some(Self::Deny),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Prompt => "prompt",
Self::Deny => "deny",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThemeTransitionMode {
#[default]
On,
Off,
Ms(u64),
}
impl ThemeTransitionMode {
pub const DEFAULT_MS: u64 = 350;
pub const MAX_MS: u64 = 2000;
pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
match val.to_ascii_lowercase().as_str() {
"on" | "true" | "yes" => Some(ThemeTransitionMode::On),
"off" | "false" | "no" => Some(ThemeTransitionMode::Off),
other => other
.parse::<u64>()
.ok()
.map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
}
}
pub fn duration_ms(self) -> u64 {
match self {
ThemeTransitionMode::On => Self::DEFAULT_MS,
ThemeTransitionMode::Off => 0,
ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
}
}
}
#[derive(Debug, Clone)]
pub struct EventsConfig {
pub auto_turn: bool,
pub auto_turn_cap: u32,
}
pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
impl Default for EventsConfig {
fn default() -> Self {
Self {
auto_turn: true,
auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
}
}
}
pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
let normalised = val.trim().to_lowercase();
match normalised.as_str() {
"unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
n => n.parse::<u32>().ok(),
}
}
fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) -> Result<(), String> {
if key == "events.auto_turn" {
let normalised = val.trim().to_lowercase();
match normalised.as_str() {
"true" | "1" | "yes" | "on" => cfg.auto_turn = true,
"false" | "0" | "no" | "off" => cfg.auto_turn = false,
other => {
cfg.auto_turn = false;
return Err(format!(
"warning: config: unrecognised value for events.auto_turn = {:?}; \
expected true/false/yes/no/on/off/1/0 — defaulting to false",
other
));
}
}
} else if key == "events.auto_turn_cap" {
match parse_auto_turn_cap(val) {
Some(cap) => cfg.auto_turn_cap = cap,
None => {
return Err(format!(
"warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
— keeping default {}",
val.trim(),
DEFAULT_AUTO_TURN_CAP
));
}
}
} Ok(())
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TurnBudgetOverrides {
pub max_provider_rounds: Option<u32>,
pub max_tool_calls: Option<u32>,
pub max_elapsed_secs: Option<u64>,
pub max_accumulated_tool_result_bytes: Option<usize>,
pub max_context_tokens: Option<u64>,
pub max_cost_usd: Option<f64>,
pub max_round_renewals: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TurnBudgetsConfig {
pub foreground: TurnBudgetOverrides,
pub autonomous: TurnBudgetOverrides,
pub worker: TurnBudgetOverrides,
}
fn parse_turn_budget_config_key(
budgets: &mut TurnBudgetsConfig,
key: &str,
val: &str,
) -> Result<(), String> {
let Some(rest) = key.strip_prefix("turn_budget.") else {
return Ok(());
};
let Some((role, field)) = rest.split_once('.') else {
return Err(format!(
"Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)"
));
};
let overrides = match role {
"foreground" => &mut budgets.foreground,
"autonomous" => &mut budgets.autonomous,
"worker" => &mut budgets.worker,
other => {
return Err(format!(
"Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)"
));
}
};
macro_rules! set {
($slot:expr, $ty:ty) => {
match val.parse::<$ty>() {
Ok(parsed) => $slot = Some(parsed),
Err(_) => {
return Err(format!("Warning: invalid value for {key}: '{val}', using default"))
}
}
};
}
match field {
"max_provider_rounds" => set!(overrides.max_provider_rounds, u32),
"max_tool_calls" => set!(overrides.max_tool_calls, u32),
"max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
"max_accumulated_tool_result_bytes" => {
set!(overrides.max_accumulated_tool_result_bytes, usize)
}
"max_context_tokens" => set!(overrides.max_context_tokens, u64),
"max_cost_usd" => set!(overrides.max_cost_usd, f64),
"max_round_renewals" => set!(overrides.max_round_renewals, u32),
other => return Err(format!("Warning: unknown turn_budget field '{other}'")),
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryConfig {
pub default_mode: String,
pub default_mode_confirmed: bool,
pub recall_max_records: u32,
pub recall_max_tokens: u32,
pub recall_timeout_ms: u64,
pub capture_tools: String,
pub capture_assistant: bool,
pub capture_user: bool,
pub auto_consolidate: String,
pub local_embeddings: String,
pub gliner: String,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
default_mode: "off".to_string(),
default_mode_confirmed: false,
recall_max_records: 8,
recall_max_tokens: 4096,
recall_timeout_ms: 150,
capture_tools: "summary_only".to_string(),
capture_assistant: true,
capture_user: true,
auto_consolidate: "off".to_string(),
local_embeddings: "off".to_string(),
gliner: "off".to_string(),
}
}
}
const KNOWN_MEMORY_MODES: &[&str] = &[
"off",
"recall_once",
"recall_each_prompt",
"capture_only",
"capture_and_recall",
];
fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) -> Result<(), String> {
let Some(field) = key.strip_prefix("memory.") else {
return Ok(());
};
fn parse_bool(slot: &mut bool, key: &str, val: &str) -> Result<(), String> {
match val {
"true" | "1" | "yes" | "on" => *slot = true,
"false" | "0" | "no" | "off" => *slot = false,
other => {
return Err(format!(
"Warning: invalid value for {key}: '{other}', expected true/false — using default"
))
}
}
Ok(())
}
fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) -> Result<(), String> {
if allowed.contains(&val) {
*slot = val.to_string();
Ok(())
} else {
Err(format!(
"Warning: invalid value for {key}: '{val}', expected one of {} — using default",
allowed.join("|")
))
}
}
macro_rules! set_num {
($slot:expr, $ty:ty) => {
match val.parse::<$ty>() {
Ok(parsed) => {
$slot = parsed;
Ok(())
}
Err(_) => Err(format!("Warning: invalid value for {key}: '{val}', using default")),
}
};
}
match field {
"default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
"default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
"recall_max_records" => set_num!(memory.recall_max_records, u32),
"recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
"recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
"capture_tools" => parse_enum(
&mut memory.capture_tools,
key,
val,
&["off", "summary_only"],
),
"capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
"capture_user" => parse_bool(&mut memory.capture_user, key, val),
"auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
"local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
"gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
other => Err(format!("Warning: unknown memory field '{other}'")),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MemoryBackendKind {
#[default]
Legacy,
Axel,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MemoryBackendConfig {
pub kind: MemoryBackendKind,
pub executable: Option<PathBuf>,
pub brain: Option<PathBuf>,
pub user_scope: bool,
}
#[derive(Debug, Clone)]
pub struct SynapsConfig {
pub model: Option<String>,
pub thinking_budget: Option<u32>,
pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
pub context_window: Option<u64>, pub context_management: ContextManagementConfig,
pub startup: StartupConfig,
pub daemon: DaemonConfig,
pub subagent: SubagentLimits,
pub compaction_model: Option<String>, pub compaction_mode: crate::core::compaction::CompactionMode,
pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
pub max_tool_output: usize, pub bash_timeout: u64, pub bash_max_timeout: u64, pub subagent_timeout: u64, pub api_retries: u32, pub refusal_retries: u32, pub telemetry: String, pub cache_diagnostics: bool, pub cache_ttl: CacheTtl,
pub cache_keepwarm: CacheKeepWarm,
pub cache_keepwarm_auto_max_hours: u32,
pub max_fps: u32,
pub scroll_lines: Option<u16>,
pub theme: Option<String>,
pub theme_transition: ThemeTransitionMode,
pub tui_background_opaque: bool,
pub tui_streaming_glow: bool,
pub process_scopes: bool,
pub agent_name: Option<String>,
pub identity: Option<String>,
pub disabled_plugins: Vec<String>,
pub favorite_models: Vec<String>,
pub disabled_skills: Vec<String>,
pub progressive_tool_disclosure: bool,
pub tools_activation_confirm: ActivationConfirm,
pub tools_deferred_loading: bool,
pub session_persistence: crate::core::session_journal::SessionPersistence,
pub disabled_tools: Vec<String>,
pub shell: ShellConfig,
pub server: ServerConfig,
pub bridge: BridgeConfig,
pub auth: AuthConfig,
pub events: EventsConfig,
pub provider_keys: BTreeMap<String, String>,
pub keybinds: std::collections::HashMap<String, String>,
pub turn_budgets: TurnBudgetsConfig,
pub memory: MemoryConfig,
pub memory_backend: MemoryBackendConfig,
pub warnings: Vec<String>,
}
impl Default for SynapsConfig {
fn default() -> Self {
Self {
model: None,
thinking_budget: None,
thinking_level: None,
context_window: None,
context_management: ContextManagementConfig::default(),
startup: StartupConfig::default(),
daemon: DaemonConfig::default(),
subagent: SubagentLimits::default(),
compaction_model: None,
compaction_mode: crate::core::compaction::CompactionMode::default(),
compaction_exclude: Vec::new(),
max_tool_output: 30000,
bash_timeout: 30,
bash_max_timeout: 300,
subagent_timeout: 300,
api_retries: 3,
refusal_retries: 2,
telemetry: "off".to_string(),
cache_diagnostics: false,
cache_ttl: CacheTtl::default(),
cache_keepwarm: CacheKeepWarm::Off,
cache_keepwarm_auto_max_hours: DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS,
max_fps: 60,
scroll_lines: None,
theme: None,
theme_transition: ThemeTransitionMode::default(),
tui_background_opaque: true,
tui_streaming_glow: false,
process_scopes: true,
agent_name: None,
identity: None,
disabled_plugins: Vec::new(),
favorite_models: Vec::new(),
disabled_skills: Vec::new(),
progressive_tool_disclosure: false,
tools_activation_confirm: ActivationConfirm::default(),
tools_deferred_loading: true,
session_persistence: crate::core::session_journal::SessionPersistence::default(),
disabled_tools: Vec::new(),
shell: ShellConfig::default(),
server: ServerConfig::default(),
bridge: BridgeConfig::default(),
auth: AuthConfig::default(),
events: EventsConfig::default(),
provider_keys: BTreeMap::new(),
keybinds: std::collections::HashMap::new(),
turn_budgets: TurnBudgetsConfig::default(),
memory: MemoryConfig::default(),
memory_backend: MemoryBackendConfig::default(),
warnings: Vec::new(),
}
}
}
pub const KNOWN_CONFIG_KEYS: &[&str] = &[
"model",
"thinking",
"compaction_model",
"compaction_mode",
"compaction_exclude",
"context_window",
"context_management.mode",
"context_management.pressure_tokens",
"context_management.rollover_tokens",
"context_management.reserve_tokens",
"context_management.finish_rounds",
"startup.quick_start",
"startup.extensions_ready_timeout_secs",
"daemon.idle_exit_secs",
"daemon.prompt_abandon_secs",
"daemon.parked_evict_secs",
"max_tool_output",
"bash_timeout",
"bash_max_timeout",
"subagent_timeout",
"subagent.max_concurrent",
"subagent.max_total",
"subagent.max_depth",
"api_retries",
"refusal_retries",
"telemetry",
"cache_diagnostics",
"cache_ttl",
"cache_keepwarm",
"cache_keepwarm_auto_max_hours",
"max_fps",
"scroll_lines",
"theme",
"theme_transition",
"tui_background_opaque",
"tui_streaming_glow",
"process_scopes",
"agent_name",
"identity",
"disabled_plugins",
"favorite_models",
"disabled_skills",
"disabled_tools",
"progressive_tool_disclosure",
"tools.activation_confirm",
"tools.deferred_loading",
"session_persistence",
"sidecar_toggle_key",
];
pub const FAMILY_CONFIG_KEYS: &[&str] = &[
"shell.max_sessions",
"shell.idle_timeout",
"shell.readiness_timeout_ms",
"shell.max_readiness_timeout_ms",
"shell.default_rows",
"shell.default_cols",
"shell.readiness_strategy",
"shell.max_output",
"events.auto_turn",
"events.auto_turn_cap",
"turn_budget.foreground.max_provider_rounds",
"turn_budget.foreground.max_tool_calls",
"turn_budget.foreground.max_elapsed_secs",
"turn_budget.foreground.max_accumulated_tool_result_bytes",
"turn_budget.foreground.max_context_tokens",
"turn_budget.foreground.max_cost_usd",
"turn_budget.foreground.max_round_renewals",
"turn_budget.autonomous.max_provider_rounds",
"turn_budget.autonomous.max_tool_calls",
"turn_budget.autonomous.max_elapsed_secs",
"turn_budget.autonomous.max_accumulated_tool_result_bytes",
"turn_budget.autonomous.max_context_tokens",
"turn_budget.autonomous.max_cost_usd",
"turn_budget.autonomous.max_round_renewals",
"turn_budget.worker.max_provider_rounds",
"turn_budget.worker.max_tool_calls",
"turn_budget.worker.max_elapsed_secs",
"turn_budget.worker.max_accumulated_tool_result_bytes",
"turn_budget.worker.max_context_tokens",
"turn_budget.worker.max_cost_usd",
"turn_budget.worker.max_round_renewals",
"memory.backend",
"memory.user_scope",
"memory.axel.executable",
"memory.axel.brain",
"memory.default_mode",
"memory.default_mode_confirmed",
"memory.recall_max_records",
"memory.recall_max_tokens",
"memory.recall_timeout_ms",
"memory.capture_tools",
"memory.capture_assistant",
"memory.capture_user",
"memory.auto_consolidate",
"memory.local_embeddings",
"memory.gliner",
"server.allowed_origins",
"server.token",
"server.auto_approve_confirms",
"server.max_message_size",
"bridge.uds_path",
"bridge.heartbeat_mirror",
"bridge.heartbeat_timeout_ms",
"auth.remote_endpoint",
"auth.machine_token",
"provider.*",
"keybind.*",
];
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
fn did_you_mean(key: &str) -> Option<&'static str> {
KNOWN_CONFIG_KEYS
.iter()
.map(|k| (*k, levenshtein(key, k)))
.filter(|(_, d)| *d <= 2)
.min_by_key(|(_, d)| *d)
.map(|(k, _)| k)
}
fn parse_comma_list(val: &str) -> Vec<String> {
val.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
write_config_value_locked(key, &values.join(", "))
}
fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) -> Result<(), String> {
let invalid = || format!("Warning: invalid value for {key}: '{val}', using default");
match key {
"shell.max_sessions" => {
shell_config.max_sessions = val.parse::<usize>().map_err(|_| invalid())?;
}
"shell.idle_timeout" => {
let timeout = val.parse::<u64>().map_err(|_| invalid())?;
shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
}
"shell.readiness_timeout_ms" => {
shell_config.readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
}
"shell.max_readiness_timeout_ms" => {
shell_config.max_readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
}
"shell.default_rows" => {
shell_config.default_rows = val.parse::<u16>().map_err(|_| invalid())?;
}
"shell.default_cols" => {
shell_config.default_cols = val.parse::<u16>().map_err(|_| invalid())?;
}
"shell.readiness_strategy" => {
let val_lower = val.to_lowercase();
match val_lower.as_str() {
"timeout" | "prompt" | "hybrid" => {
shell_config.readiness_strategy = val.to_string();
}
_ => return Err(invalid()),
}
}
"shell.max_output" => {
shell_config.max_output = val.parse::<usize>().map_err(|_| invalid())?;
}
_ => {
}
}
Ok(())
}
#[allow(clippy::collapsible_match)]
fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
match key {
"server.allowed_origins" => {
server_config.allowed_origins = parse_comma_list(val);
}
"server.token" => {
if !val.is_empty() {
server_config.token = Some(val.to_string());
}
}
"server.auto_approve_confirms" => {
server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
}
"server.max_message_size" => {
if let Ok(size) = val.parse::<usize>() {
server_config.max_message_size = Some(size);
} else {
eprintln!(
"Warning: invalid value for server.max_message_size: '{}', ignored",
val
);
}
}
_ => {
}
}
}
fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
match key {
"bridge.uds_path" => {
if val.is_empty() {
bridge_config.uds_path = None;
} else {
bridge_config.uds_path = Some(PathBuf::from(val));
}
}
"bridge.heartbeat_mirror" => {
bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
}
"bridge.heartbeat_timeout_ms" => {
if let Ok(ms) = val.parse::<u64>() {
bridge_config.heartbeat_timeout_ms = ms;
} else {
eprintln!(
"Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
val
);
}
}
_ => {
}
}
}
fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
let v = val.trim();
match key {
"auth.remote_endpoint" => {
auth_config.remote_endpoint = if v.is_empty() {
None
} else {
Some(v.to_string())
};
}
"auth.machine_token" => {
auth_config.machine_token = if v.is_empty() {
None
} else {
Some(v.to_string())
};
}
_ => {
}
}
}
pub fn load_config_from_str(content: &str) -> SynapsConfig {
let mut config = SynapsConfig::default();
apply_config_content(&mut config, content);
config
}
pub fn config_content_problems(content: &str) -> Vec<String> {
let mut config = SynapsConfig::default();
let mut problems = Vec::new();
apply_config_content_with(&mut config, content, &mut |diagnostic: String| problems.push(diagnostic));
problems.extend(config.warnings);
problems
}
pub fn validate_config_edit(key: &str, value: &str) -> Result<(), String> {
if key.contains(['=', '\n', '\r'])
|| value.contains(['\n', '\r'])
{
return Err("value must be a single line".to_string());
}
let target = get_active_config_dir().join("config");
let existing = std::fs::read_to_string(&target).unwrap_or_default();
let before = config_content_problems(&existing);
let after = config_content_problems(&replace_config_line(&existing, key, value));
let introduced: Vec<String> = after.into_iter().filter(|p| !before.contains(p)).collect();
if introduced.is_empty() {
Ok(())
} else {
Err(introduced.join("; "))
}
}
pub fn load_config() -> SynapsConfig {
let path = resolve_read_path("config");
let mut config = SynapsConfig::default();
let Ok(content) = std::fs::read_to_string(&path) else {
return config;
};
apply_config_content(&mut config, &content);
let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
let identity_val = config
.identity
.clone()
.unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
let _ = IDENTITY.set(identity_val);
config
}
fn apply_config_content(config: &mut SynapsConfig, content: &str) {
apply_config_content_with(config, content, &mut |diagnostic: String| eprintln!("{diagnostic}"));
}
fn apply_config_content_with(
config: &mut SynapsConfig,
content: &str,
family_diagnostic: &mut dyn FnMut(String),
) {
let mut invalid_context_management = false;
let mut invalid_memory_backend = false;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, val)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let val = val.trim();
match key {
"model" => config.model = Some(val.to_string()),
"thinking" => {
use crate::core::reasoning::ThinkingSpec;
match ThinkingSpec::parse(val) {
Some(ThinkingSpec::Named(level)) => {
config.thinking_level = Some(level);
config.thinking_budget = level.to_legacy_budget();
}
Some(ThinkingSpec::Budget(budget)) => {
config.thinking_level = None;
config.thinking_budget = Some(budget);
}
None => {
config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
}
}
}
"compaction_model" => config.compaction_model = Some(val.to_string()),
"compaction_mode" => match val {
"remote" => {
config.compaction_mode = crate::core::compaction::CompactionMode::Remote
}
"local" | "local_only" | "local-only" => {
config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
}
_ => {
config.warnings.push(format!(
"compaction_mode = {val} — expected remote or local; ignored"
));
}
},
"compaction_exclude" => {
let mut classes = Vec::new();
for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
match crate::core::compaction::ContentClass::parse(part) {
Some(class) => {
if !classes.contains(&class) {
classes.push(class);
}
}
None => config.warnings.push(format!(
"compaction_exclude — unknown content class '{part}'; \
expected one of: user_text, assistant_text, thinking, \
tool_calls, tool_results, file_paths, event_data"
)),
}
}
config.compaction_exclude = classes;
}
"context_window" if val.eq_ignore_ascii_case("auto") => config.context_window = None,
"context_window" => {
let parsed = match val {
"200k" | "200K" => Some(200_000),
"1m" | "1M" => Some(1_000_000),
_ => val.parse::<u64>().ok(),
};
if parsed.is_none() {
config.warnings.push(format!(
"context_window = {val} — expected 200k, 1m, auto, or a token count; ignored"
));
}
config.context_window = parsed;
}
"max_tool_output" => match val.parse::<usize>() {
Ok(size) => config.max_tool_output = size,
Err(_) => config.warnings.push(format!(
"max_tool_output = {val} — not a number; using {}",
config.max_tool_output
)),
},
"bash_timeout" => match val.parse::<u64>() {
Ok(t) if t >= 1 => config.bash_timeout = t,
Ok(_) => config.warnings.push(format!(
"bash_timeout = {val} — below minimum (1s); using {}",
config.bash_timeout
)),
Err(_) => config.warnings.push(format!(
"bash_timeout = {val} — not a number; using {}",
config.bash_timeout
)),
},
"bash_max_timeout" => {
if let Ok(timeout) = val.parse::<u64>() {
config.bash_max_timeout = timeout;
}
}
"subagent_timeout" => {
if let Ok(timeout) = val.parse::<u64>() {
config.subagent_timeout = timeout;
}
}
"api_retries" => {
if let Ok(retries) = val.parse::<u32>() {
config.api_retries = retries;
}
}
"refusal_retries" => {
if let Ok(retries) = val.parse::<u32>() {
config.refusal_retries = retries;
}
}
"telemetry" => config.telemetry = val.to_string(),
"cache_diagnostics" => {
config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
}
"cache_ttl" => match CacheTtl::parse(val) {
Some(ttl) => config.cache_ttl = ttl,
None => config.warnings.push(format!(
"cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
)),
},
"cache_keepwarm" => match CacheKeepWarm::parse(val) {
Some(mode) => config.cache_keepwarm = mode,
None => config.warnings.push(format!(
"cache_keepwarm = {val} — expected off, auto, or 1h–{MAX_CACHE_KEEPWARM_HOURS}h; keep-warm stays off"
)),
},
"cache_keepwarm_auto_max_hours" => {
match val.trim().trim_end_matches(['h', 'H']).parse::<u32>() {
Ok(h) if (1..=MAX_CACHE_KEEPWARM_HOURS).contains(&h) => {
config.cache_keepwarm_auto_max_hours = h
}
_ => config.warnings.push(format!(
"cache_keepwarm_auto_max_hours = {val} — expected 1–{MAX_CACHE_KEEPWARM_HOURS}; using {DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS}"
)),
}
}
"max_fps" => match val.parse::<u32>() {
Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
Ok(_) => config.warnings.push(format!(
"max_fps = {val} — expected 1–1000; using {}",
config.max_fps
)),
Err(_) => config.warnings.push(format!(
"max_fps = {val} — not a number; using {}",
config.max_fps
)),
},
"scroll_lines" => match val.parse::<u16>() {
Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
Ok(_) => config
.warnings
.push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
Err(_) => config
.warnings
.push(format!("scroll_lines = {val} — not a number; ignoring")),
},
"theme" => config.theme = Some(val.to_string()),
"theme_transition" => match ThemeTransitionMode::parse(val) {
Some(mode) => config.theme_transition = mode,
None => config.warnings.push(format!(
"theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
)),
},
"tui_background_opaque" => match val {
"true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
"false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
_ => config.warnings.push(format!(
"tui_background_opaque = {val} — expected opaque or invisible; using {}",
if config.tui_background_opaque {
"opaque"
} else {
"invisible"
}
)),
},
"tui_streaming_glow" => match val {
"true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
"false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
_ => config.warnings.push(format!(
"tui_streaming_glow = {val} — expected on or off; using off"
)),
},
"process_scopes" => match val {
"true" | "1" | "on" | "yes" | "auto" => config.process_scopes = true,
"false" | "0" | "off" | "no" => config.process_scopes = false,
_ => config.warnings.push(format!(
"process_scopes = {val} — expected auto or off; using auto"
)),
},
"sidecar_toggle_key" => {}
"agent_name" => config.agent_name = Some(val.to_string()),
"identity" => config.identity = Some(val.to_string()),
"disabled_plugins" => {
config.disabled_plugins = parse_comma_list(val);
}
"favorite_models" => {
config.favorite_models = parse_comma_list(val);
}
"disabled_skills" => {
config.disabled_skills = parse_comma_list(val);
}
"progressive_tool_disclosure" => {
config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
}
"tools.deferred_loading" => match val {
"on" | "true" | "1" | "yes" => config.tools_deferred_loading = true,
"off" | "false" | "0" | "no" => config.tools_deferred_loading = false,
_ => config.warnings.push(format!(
"tools.deferred_loading = {val} — expected on or off; keeping the default (on)"
)),
},
"tools.activation_confirm" => match ActivationConfirm::parse(val) {
Some(mode) => config.tools_activation_confirm = mode,
None => config.warnings.push(format!(
"tools.activation_confirm = {val} — expected auto, prompt or deny; \
keeping the default (auto)"
)),
},
"session_persistence" => {
match crate::core::session_journal::SessionPersistence::parse(val) {
Some(mode) => config.session_persistence = mode,
None => config.warnings.push(format!(
"session_persistence = {val} — expected json or journal; \
keeping the default (json)"
)),
}
}
"disabled_tools" => {
config.disabled_tools = parse_comma_list(val);
}
"memory.backend" => {
config.memory_backend.kind = match val {
"legacy" => MemoryBackendKind::Legacy,
"axel" => MemoryBackendKind::Axel,
_ => {
invalid_memory_backend = true;
config.warnings.push(format!(
"memory.backend = {val} — expected legacy or axel; memory backend unavailable"
));
MemoryBackendKind::Unavailable
}
};
}
"memory.user_scope" => match val {
"true" => config.memory_backend.user_scope = true,
"false" => config.memory_backend.user_scope = false,
_ => {
invalid_memory_backend = true;
config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
}
},
"memory.axel.executable" | "memory.axel.brain" => {
let path = PathBuf::from(val);
if !path.is_absolute() {
invalid_memory_backend = true;
config.warnings.push(format!(
"{key} — expected an absolute path; memory backend unavailable"
));
}
if key == "memory.axel.executable" {
config.memory_backend.executable = Some(path);
} else {
config.memory_backend.brain = Some(path);
}
}
_ => {
if key.starts_with("shell.") {
if let Err(diagnostic) = parse_shell_config_key(&mut config.shell, key, val) {
family_diagnostic(diagnostic);
}
} else if key.starts_with("server.") {
parse_server_config_key(&mut config.server, key, val);
} else if key.starts_with("bridge.") {
parse_bridge_config_key(&mut config.bridge, key, val);
} else if key.starts_with("auth.") {
parse_auth_config_key(&mut config.auth, key, val);
} else if key.starts_with("events.") {
if let Err(diagnostic) = parse_events_config_key(&mut config.events, key, val)
{
family_diagnostic(diagnostic);
}
} else if key.starts_with("turn_budget.") {
if let Err(diagnostic) =
parse_turn_budget_config_key(&mut config.turn_budgets, key, val)
{
family_diagnostic(diagnostic);
}
} else if key.starts_with("memory.") {
if let Err(diagnostic) = parse_memory_config_key(&mut config.memory, key, val)
{
family_diagnostic(diagnostic);
}
} else if key.starts_with("startup.") {
if let Err(reason) =
parse_startup_config_key(&mut config.startup, key, val)
{
config.warnings.push(format!("{key} — {reason}"));
}
} else if key.starts_with("daemon.") {
if let Err(reason) =
parse_daemon_config_key(&mut config.daemon, key, val)
{
config.warnings.push(format!("{key} — {reason}"));
}
} else if key.starts_with("subagent.") {
if let Err(reason) =
parse_subagent_config_key(&mut config.subagent, key, val)
{
config.warnings.push(format!("{key} — {reason}"));
}
} else if key.starts_with("context_management.") {
if let Err(reason) = parse_context_management_config_key(
&mut config.context_management, key, val,
) {
invalid_context_management = true;
config.warnings.push(format!("{key} — {reason}"));
}
} else if let Some(provider_key) = key.strip_prefix("provider.") {
config
.provider_keys
.insert(provider_key.to_string(), val.to_string());
} else if let Some(keybind_key) = key.strip_prefix("keybind.") {
config
.keybinds
.insert(keybind_key.to_string(), val.to_string());
} else if key.contains('.') {
} else {
match did_you_mean(key) {
Some(suggestion) => config.warnings.push(format!(
"unknown key '{key}' (did you mean '{suggestion}'?)"
)),
None => config
.warnings
.push(format!("unknown key '{key}' — ignored")),
}
}
}
}
}
let context_validation = match config.context_window {
Some(window) => config.context_management.validate_for_window(window),
None => config.context_management.validate(),
};
if let Err(reason) = context_validation {
invalid_context_management = true;
config
.warnings
.push(format!("context_management — {reason}"));
}
if config.subagent.max_concurrent > config.subagent.max_total {
config.warnings.push(format!(
"subagent.max_concurrent ({}) is above subagent.max_total ({}); using {} for both",
config.subagent.max_concurrent, config.subagent.max_total, config.subagent.max_total
));
config.subagent.max_concurrent = config.subagent.max_total;
}
if invalid_context_management {
config.context_management = ContextManagementConfig::default();
config.warnings.push(
"invalid context_management configuration — using defaults (mode off)".to_string(),
);
}
if invalid_memory_backend {
config.memory_backend.kind = MemoryBackendKind::Unavailable;
}
if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
config.warnings.push(format!(
"memory.default_mode = {} requires memory.default_mode_confirmed = true \
in the same config; reverting to off",
config.memory.default_mode
));
config.memory.default_mode = "off".to_string();
}
if config.server.max_message_size.is_none() {
if let Some(ctx_tokens) = config.context_window {
config.server.max_message_size = Some((ctx_tokens as usize) * 4);
}
}
}
pub fn read_config_value(key: &str) -> Option<String> {
let path = resolve_read_path("config");
let content = std::fs::read_to_string(&path).ok()?;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() == key.trim() {
return Some(v.trim().to_string());
}
}
None
}
struct ConfigLock {
_file: Option<std::fs::File>,
}
impl ConfigLock {
fn acquire() -> std::io::Result<Self> {
if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
return Ok(Self { _file: None });
}
use fs4::fs_std::FileExt;
let lock_path = resolve_write_path("config").with_extension("lock");
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut open = std::fs::OpenOptions::new();
open.create(true).write(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
open.mode(0o600);
}
let file = open.open(&lock_path)?;
FileExt::lock_exclusive(&file)?;
Ok(Self { _file: Some(file) })
}
}
pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
let _guard = ConfigLock::acquire()?;
write_config_value_locked(key, value)
}
fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
let path = resolve_write_path("config");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let out = replace_config_line(&existing, key, value);
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, out)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, &path)?;
Ok(())
}
fn replace_config_line(existing: &str, key: &str, value: &str) -> String {
let key_trimmed = key.trim();
let replacement = format!("{} = {}", key_trimmed, value);
let mut found = false;
let mut new_lines: Vec<String> = existing
.lines()
.map(|line| {
if found {
return line.to_string();
}
let t = line.trim_start();
if t.starts_with('#') || t.is_empty() {
return line.to_string();
}
if let Some((k, _)) = t.split_once('=') {
if k.trim() == key_trimmed {
found = true;
return replacement.clone();
}
}
line.to_string()
})
.collect();
if !found {
new_lines.push(replacement);
}
let mut out = new_lines.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}
pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
let trimmed = id.trim();
if trimmed.is_empty() {
return Ok(());
}
let _guard = ConfigLock::acquire()?;
let mut values = load_config().favorite_models;
if !values.iter().any(|v| v == trimmed) {
values.push(trimmed.to_string());
values.sort();
}
write_comma_list_locked("favorite_models", &values)
}
pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
let _guard = ConfigLock::acquire()?;
let mut values = load_config().favorite_models;
values.retain(|v| v != id.trim());
write_comma_list_locked("favorite_models", &values)
}
pub fn is_favorite_model(id: &str) -> bool {
load_config().favorite_models.iter().any(|v| v == id.trim())
}
pub fn is_secret_key(key: &str) -> bool {
let upper = key.to_ascii_uppercase();
upper.ends_with("_KEY")
|| upper.ends_with("_TOKEN")
|| upper.ends_with("_CREDENTIALS")
|| upper.ends_with("_API_KEY")
|| upper.contains("SECRET")
|| upper.contains("PASSWORD")
|| upper.contains("PASSWD")
|| upper.ends_with("_PAT")
|| upper.ends_with("_DSN")
}
pub fn is_secret_env_value(value: &str) -> bool {
let mut rest = value;
while let Some(i) = rest.find("://") {
let scheme_ok = rest[..i]
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric());
let after = &rest[i + 3..];
let end = after
.find(|c: char| {
matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
})
.unwrap_or(after.len());
if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
return true;
}
rest = after;
}
false
}
pub fn is_secret_env(key: &str, value: &str) -> bool {
is_secret_key(key) || is_secret_env_value(value)
}
pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
#[cfg(windows)]
const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
You have access to powershell, bash, read, and write tools. \
Prefer the powershell tool for system commands and filesystem operations — it is native \
to Windows and avoids quoting/path translation issues. \
Use bash only when the task specifically requires WSL or a Unix environment. \
Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
#[cfg(not(windows))]
const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
You have access to bash, read, and write tools. \
Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
if let Some(val) = explicit {
let path = std::path::Path::new(val);
if path.exists() && path.is_file() {
return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
}
return val.to_string();
}
let system_path = resolve_read_path("system.md");
if system_path.exists() {
return std::fs::read_to_string(&system_path).unwrap_or_default();
}
DEFAULT_PROMPT.to_string()
}
#[cfg(test)]
mod tests {
#[test]
fn tools_deferred_loading_defaults_on_and_parses() {
assert!(super::load_config_from_str("").tools_deferred_loading);
assert!(
!super::load_config_from_str("tools.deferred_loading = off\n").tools_deferred_loading
);
let bad = super::load_config_from_str("tools.deferred_loading = maybe\n");
assert!(
bad.tools_deferred_loading,
"invalid value keeps the default"
);
assert!(bad
.warnings
.iter()
.any(|w| w.contains("tools.deferred_loading")));
}
#[test]
fn secret_env_value_detects_credential_urls() {
use super::is_secret_env_value as v;
assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
assert!(v("redis://:pw@cache:6379/0"));
assert!(v("http://user:pass@proxy.corp:3128"));
assert!(v(
"mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
));
assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
assert!(!v("https://api.example.com/v1/items?q=a@b"));
assert!(!v("http://host/path/with@sign"));
assert!(!v("https://@host/empty-userinfo"));
assert!(!v("git@github.com:org/repo.git"));
assert!(!v("mailto:someone@example.com"));
assert!(!v("someone@example.com"));
assert!(!v("/usr/local/bin:/usr/bin"));
assert!(!v(""));
}
#[test]
fn secret_key_covers_pat_and_dsn() {
assert!(super::is_secret_key("GITHUB_PAT"));
assert!(super::is_secret_key("gh_pat"));
assert!(super::is_secret_key("SENTRY_DSN"));
assert!(!super::is_secret_key("PATH"));
assert!(!super::is_secret_key("PATTERN_DIR"));
}
#[test]
fn secret_env_is_key_or_value() {
assert!(super::is_secret_env(
"DATABASE_URL",
"postgres://app:hunter2@db/prod"
));
assert!(super::is_secret_env("GH_TOKEN", "anything"));
assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
assert!(!super::is_secret_env("HOME", "/home/u"));
}
#[test]
fn startup_and_daemon_defaults() {
let c = super::load_config_from_str("");
assert!(c.startup.quick_start, "quick_start defaults ON");
assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
assert_eq!(c.daemon.idle_exit_secs, 10);
assert_eq!(c.daemon.prompt_abandon_secs, 3600);
assert_eq!(c.daemon.parked_evict_secs, 3600);
assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
}
#[test]
fn startup_and_daemon_parse_all_keys() {
let c = super::load_config_from_str(concat!(
"startup.quick_start = off\n",
"startup.extensions_ready_timeout_secs = 45\n",
"daemon.idle_exit_secs = 20\n",
"daemon.prompt_abandon_secs = 0\n",
"daemon.parked_evict_secs = 120\n",
));
assert!(!c.startup.quick_start);
assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
assert_eq!(c.daemon.idle_exit_secs, 20);
assert_eq!(c.daemon.prompt_abandon_secs, 0);
assert_eq!(c.daemon.parked_evict_secs, 120);
assert!(
!c.warnings.iter().any(|w| w.contains("unknown")),
"no unknown-key warning: {:?}",
c.warnings
);
}
#[test]
fn subagent_limits_default_to_16_64_4() {
let c = super::load_config_from_str("");
assert_eq!(c.subagent, super::SubagentLimits::default());
assert_eq!(c.subagent.max_concurrent, 16);
assert_eq!(c.subagent.max_total, 64);
assert_eq!(c.subagent.max_depth, 4);
assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
}
#[test]
fn subagent_limits_parse_and_are_known_keys() {
let c = super::load_config_from_str(concat!(
"subagent.max_concurrent = 24\n",
"subagent.max_total = 100\n",
"subagent.max_depth = 2\n",
"subagent_timeout = 600\n",
));
assert_eq!(c.subagent.max_concurrent, 24);
assert_eq!(c.subagent.max_total, 100);
assert_eq!(c.subagent.max_depth, 2);
assert_eq!(c.subagent_timeout, 600, "the old flat key is unaffected");
assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
for k in [
"subagent.max_concurrent",
"subagent.max_total",
"subagent.max_depth",
] {
assert!(
super::KNOWN_CONFIG_KEYS.contains(&k),
"{k} not in KNOWN_CONFIG_KEYS"
);
}
}
#[test]
fn subagent_limits_reject_zero_and_junk_and_keep_defaults() {
let c = super::load_config_from_str(concat!(
"subagent.max_concurrent = 0\n",
"subagent.max_total = lots\n",
"subagent.max_depth = 70000\n",
"subagent.max_children = 4\n",
));
assert_eq!(c.subagent, super::SubagentLimits::default());
for key in [
"subagent.max_concurrent",
"subagent.max_total",
"subagent.max_depth",
"subagent.max_children",
] {
assert!(
c.warnings.iter().any(|w| w.starts_with(key)),
"expected a warning for {key}: {:?}",
c.warnings
);
}
}
#[test]
fn subagent_concurrency_above_total_uses_the_stricter_number() {
for text in [
"subagent.max_total = 10\nsubagent.max_concurrent = 32\n",
"subagent.max_concurrent = 32\nsubagent.max_total = 10\n",
] {
let c = super::load_config_from_str(text);
assert_eq!(c.subagent.max_concurrent, 10);
assert_eq!(c.subagent.max_total, 10);
assert!(
c.warnings
.iter()
.any(|w| w.contains("subagent.max_concurrent (32)")),
"{:?}",
c.warnings
);
}
let c = super::load_config_from_str("subagent.max_total = 5\n");
assert_eq!(c.subagent.max_concurrent, 5);
}
#[test]
fn process_scopes_defaults_on_and_parses_auto_and_off() {
assert!(super::load_config_from_str("").process_scopes);
assert!(super::load_config_from_str("process_scopes = auto\n").process_scopes);
let off = super::load_config_from_str("process_scopes = off\n");
assert!(!off.process_scopes);
assert!(off.warnings.is_empty(), "{:?}", off.warnings);
let bad = super::load_config_from_str("process_scopes = sometimes\n");
assert!(bad.process_scopes, "an invalid value keeps the default");
assert!(bad.warnings.iter().any(|w| w.starts_with("process_scopes")));
assert!(super::KNOWN_CONFIG_KEYS.contains(&"process_scopes"));
}
#[test]
fn startup_quick_start_accepts_on() {
let c = super::load_config_from_str("startup.quick_start = on\n");
assert!(c.startup.quick_start);
}
#[test]
fn startup_and_daemon_keys_are_known() {
for k in [
"startup.quick_start",
"startup.extensions_ready_timeout_secs",
"daemon.idle_exit_secs",
"daemon.prompt_abandon_secs",
"daemon.parked_evict_secs",
] {
assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
}
}
#[test]
fn startup_daemon_bad_values_warn_and_keep_defaults() {
let c = super::load_config_from_str(concat!(
"startup.quick_start = maybe\n",
"daemon.idle_exit_secs = soon\n",
));
assert!(c.startup.quick_start, "invalid bool keeps default");
assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
}
#[test]
fn context_management_defaults_off_and_parses_all_keys() {
use super::{ContextManagementConfig, ContextManagementMode};
assert_eq!(
super::load_config_from_str("").context_management,
ContextManagementConfig::default()
);
let parsed = super::load_config_from_str(
"context_management.mode = AUTO\n\
context_management.pressure_tokens = 250000\n\
context_management.rollover_tokens = 400000\n\
context_management.reserve_tokens = 24000\n\
context_management.finish_rounds = 3\n\
context_window = 1m\n",
);
assert_eq!(
parsed.context_management,
ContextManagementConfig {
mode: ContextManagementMode::Auto,
pressure_tokens: Some(250_000),
rollover_tokens: Some(400_000),
reserve_tokens: 24_000,
finish_rounds: 3,
}
);
assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
assert!(parsed.provider_keys.is_empty());
assert!(super::load_config_from_str("context_management.mode = off")
.warnings
.is_empty());
}
#[test]
fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
let parsed = super::load_config_from_str(&format!(
"context_management.mode = auto\n\
context_management.pressure_tokens = 300000\n\
context_management.rollover_tokens = 450000\n\
context_management.pressure_tokens = auto\n\
context_management.rollover_tokens = AUTO\n\
context_management.finish_rounds = {rounds}\n"
));
assert_eq!(parsed.context_management.pressure_tokens, None);
assert_eq!(parsed.context_management.rollover_tokens, None);
assert_eq!(parsed.context_management.finish_rounds, rounds);
assert!(parsed.warnings.is_empty());
}
}
#[test]
fn context_management_invalid_values_reset_entire_policy() {
for (key, value) in [
("mode", "on"),
("pressure_tokens", "0"),
("pressure_tokens", "-1"),
("pressure_tokens", "250k"),
("pressure_tokens", "18446744073709551616"),
("rollover_tokens", "1"),
("rollover_tokens", "nope"),
("reserve_tokens", "0"),
("reserve_tokens", "1000001"),
("reserve_tokens", "auto"),
("finish_rounds", "9"),
("finish_rounds", "-1"),
("finish_rounds", "4294967296"),
("finish_rounds", ""),
("mod", "auto"),
] {
let parsed = super::load_config_from_str(&format!(
"context_management.mode = auto\ncontext_management.{key} = {value}\n"
));
assert_eq!(
parsed.context_management,
super::ContextManagementConfig::default(),
"{key}={value}"
);
assert!(!parsed.warnings.is_empty(), "{key}={value}");
assert!(parsed.provider_keys.is_empty());
}
let duplicate = super::load_config_from_str(
"context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
);
assert_eq!(
duplicate.context_management,
super::ContextManagementConfig::default()
);
}
#[test]
fn context_management_validates_final_threshold_order_and_window() {
for fields in [
"pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
"pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
"pressure_tokens = 1000000",
"rollover_tokens = 1000001",
] {
for window_first in [false, true] {
let policy =
format!("context_management.mode = auto\ncontext_management.{fields}\n");
let content = if window_first {
format!("context_window = 1m\n{policy}")
} else {
format!("{policy}context_window = 1m\n")
};
let parsed = super::load_config_from_str(&content);
assert_eq!(
parsed.context_management,
super::ContextManagementConfig::default()
);
assert!(!parsed.warnings.is_empty());
}
}
let parsed = super::load_config_from_str(
"context_management.mode = auto\n\
context_management.rollover_tokens = 100000\n\
context_management.pressure_tokens = 300000\n\
context_management.rollover_tokens = 450000\ncontext_window = 1m",
);
assert!(parsed.warnings.is_empty());
assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
}
#[test]
fn context_management_parsed_finish_override_bounds_rollover_extension() {
use crate::core::context_policy::{
assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
};
let config = super::load_config_from_str(
"context_management.mode = auto\n\
context_management.finish_rounds = 1\ncontext_window = 1m",
);
let budget = ContextBudget {
context_window_tokens: 1_000_000,
used_tokens: 400_000,
hard_remaining_tokens: 600_000,
required_next_round_tokens: 8_000,
};
let mut state = ContextState::default();
state.report_phase(WorkPhase::Plan);
let first = assess_context(&config.context_management, &state, budget);
assert_eq!(
first.action,
ContextAction::FinishBounded {
rounds_remaining: 0
}
);
state = first.next_state;
state.report_phase(WorkPhase::WrapUp);
assert_eq!(
assess_context(&config.context_management, &state, budget).action,
ContextAction::Rollover
);
}
#[test]
fn context_management_keys_are_known_for_suggestions() {
for key in [
"mode",
"pressure_tokens",
"rollover_tokens",
"reserve_tokens",
"finish_rounds",
] {
let full = format!("context_management.{key}");
assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
assert_eq!(
super::did_you_mean(&format!("{full}x")),
Some(full.as_str())
);
}
}
#[test]
fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
use crate::core::context_policy::{
assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
};
let config =
super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
let budget = ContextBudget {
context_window_tokens: config.context_window.unwrap(),
used_tokens: 350_000,
hard_remaining_tokens: 650_000,
required_next_round_tokens: 8_000,
};
let mut state = ContextState::default();
state.report_phase(WorkPhase::Plan);
let plan = assess_context(&config.context_management, &state, budget);
assert_eq!(plan.action, ContextAction::Advisory);
state = plan.next_state;
state.report_phase(WorkPhase::Execute);
assert_eq!(
assess_context(&config.context_management, &state, budget).action,
ContextAction::Rollover
);
}
#[test]
fn tui_streaming_glow_defaults_off_and_parses() {
assert!(!super::load_config_from_str("").tui_streaming_glow);
assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
assert!(
!invalid.tui_streaming_glow,
"invalid keeps the default (off)"
);
assert!(invalid
.warnings
.iter()
.any(|w| w.contains("tui_streaming_glow")));
}
#[test]
fn tui_background_opaque_defaults_and_parses_supported_values() {
assert!(super::load_config_from_str("").tui_background_opaque);
assert!(
super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
);
assert!(
!super::load_config_from_str("tui_background_opaque = invisible\n")
.tui_background_opaque
);
let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
assert!(invalid.tui_background_opaque);
assert!(invalid
.warnings
.iter()
.any(|w| w.contains("tui_background_opaque")));
}
#[test]
fn turn_budget_keys_parse_typed_per_role() {
let config = super::load_config_from_str(
"turn_budget.worker.max_provider_rounds = 3\n\
turn_budget.worker.max_cost_usd = 0.25\n\
turn_budget.autonomous.max_elapsed_secs = 90\n\
turn_budget.foreground.max_tool_calls = 7\n\
turn_budget.foreground.max_round_renewals = 12\n\
turn_budget.worker.max_provider_rounds_bogus = 1\n\
turn_budget.nosuchrole.max_tool_calls = 1\n",
);
assert_eq!(config.turn_budgets.worker.max_provider_rounds, Some(3));
assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
assert_eq!(config.turn_budgets.foreground.max_tool_calls, Some(7));
assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
}
#[test]
fn memory_keys_all_parse_with_consent() {
let config = super::load_config_from_str(
"memory.default_mode = capture_and_recall\n\
memory.default_mode_confirmed = true\n\
memory.recall_max_records = 12\n\
memory.recall_max_tokens = 2048\n\
memory.recall_timeout_ms = 250\n\
memory.capture_tools = off\n\
memory.capture_assistant = false\n\
memory.capture_user = false\n\
memory.auto_consolidate = on\n\
memory.local_embeddings = on\n\
memory.gliner = on\n",
);
assert_eq!(config.memory.default_mode, "capture_and_recall");
assert!(config.memory.default_mode_confirmed);
assert_eq!(config.memory.recall_max_records, 12);
assert_eq!(config.memory.recall_max_tokens, 2048);
assert_eq!(config.memory.recall_timeout_ms, 250);
assert_eq!(config.memory.capture_tools, "off");
assert!(!config.memory.capture_assistant);
assert!(!config.memory.capture_user);
assert_eq!(config.memory.auto_consolidate, "on");
assert_eq!(config.memory.local_embeddings, "on");
assert_eq!(config.memory.gliner, "on");
}
#[test]
fn memory_defaults_match_spec_12() {
let config = super::load_config_from_str("");
assert_eq!(config.memory, super::MemoryConfig::default());
assert_eq!(config.memory.default_mode, "off");
assert!(!config.memory.default_mode_confirmed);
assert_eq!(config.memory.recall_max_records, 8);
assert_eq!(config.memory.recall_max_tokens, 4096);
assert_eq!(config.memory.recall_timeout_ms, 150);
assert_eq!(config.memory.capture_tools, "summary_only");
assert!(config.memory.capture_assistant);
assert!(config.memory.capture_user);
assert_eq!(config.memory.auto_consolidate, "off");
assert_eq!(config.memory.local_embeddings, "off");
assert_eq!(config.memory.gliner, "off");
}
#[test]
fn memory_unknown_values_fail_closed_to_defaults() {
let config = super::load_config_from_str(
"memory.default_mode = recall_everything_forever\n\
memory.default_mode_confirmed = true\n\
memory.recall_max_records = lots\n\
memory.recall_max_tokens = -5\n\
memory.recall_timeout_ms = fast\n\
memory.capture_tools = full\n\
memory.capture_assistant = maybe\n\
memory.capture_user = sometimes\n\
memory.auto_consolidate = aggressive\n\
memory.local_embeddings = auto\n\
memory.gliner = download\n\
memory.nosuchfield = 1\n",
);
assert_eq!(config.memory.default_mode, "off");
assert_eq!(config.memory.recall_max_records, 8);
assert_eq!(config.memory.recall_max_tokens, 4096);
assert_eq!(config.memory.recall_timeout_ms, 150);
assert_eq!(config.memory.capture_tools, "summary_only");
assert!(config.memory.capture_assistant);
assert!(config.memory.capture_user);
assert_eq!(config.memory.auto_consolidate, "off");
assert_eq!(config.memory.local_embeddings, "off");
assert_eq!(config.memory.gliner, "off");
}
#[test]
fn memory_default_mode_without_consent_reverts_to_off() {
let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
assert_eq!(config.memory.default_mode, "off");
assert!(
config
.warnings
.iter()
.any(|w| w.contains("memory.default_mode_confirmed")),
"reversion must surface a warning; got: {:?}",
config.warnings
);
let denied = super::load_config_from_str(
"memory.default_mode = capture_only\n\
memory.default_mode_confirmed = false\n",
);
assert_eq!(denied.memory.default_mode, "off");
let garbage = super::load_config_from_str(
"memory.default_mode = capture_only\n\
memory.default_mode_confirmed = definitely\n",
);
assert_eq!(garbage.memory.default_mode, "off");
}
#[test]
fn memory_consent_is_order_independent() {
let before = super::load_config_from_str(
"memory.default_mode_confirmed = true\n\
memory.default_mode = recall_once\n",
);
assert_eq!(before.memory.default_mode, "recall_once");
let after = super::load_config_from_str(
"memory.default_mode = recall_once\n\
memory.default_mode_confirmed = true\n",
);
assert_eq!(after.memory.default_mode, "recall_once");
}
#[test]
fn memory_backend_defaults_are_independent_of_consent() {
let config = super::load_config_from_str("");
assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
let axel = super::load_config_from_str("memory.backend = axel\n");
assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
assert_eq!(axel.memory, config.memory);
assert!(axel.warnings.is_empty());
}
#[test]
fn user_wide_notes_require_explicit_valid_opt_in() {
let default = super::load_config_from_str("memory.backend = axel\n");
assert!(!default.memory_backend.user_scope);
let enabled =
super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
assert!(enabled.memory_backend.user_scope);
assert_eq!(enabled.memory, default.memory);
for bad in ["yes", "", "1", "TRUE"] {
let config = super::load_config_from_str(&format!(
"memory.user_scope = {bad}\nmemory.backend = axel\n"
));
assert_eq!(
config.memory_backend.kind,
super::MemoryBackendKind::Unavailable
);
}
}
#[test]
fn memory_backend_invalid_selector_fails_closed() {
for content in [
"memory.backend = typo\n",
"memory.backend = \n",
"memory.backend = unavailable\n",
"memory.backend = axel\nmemory.backend = typo\n",
"memory.backend = typo\nmemory.backend = legacy\n",
] {
let config = super::load_config_from_str(content);
assert_eq!(
config.memory_backend.kind,
super::MemoryBackendKind::Unavailable
);
assert!(config
.warnings
.iter()
.any(|w| w.contains("memory backend unavailable")));
}
}
#[test]
fn memory_backend_absolute_paths_are_preserved() {
let root = std::env::current_dir().unwrap();
let executable = root.join("fixture bin/axel");
let brain = root.join("fixture brain");
let config = super::load_config_from_str(&format!(
"memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
brain.display(),
executable.display()
));
assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
assert_eq!(config.memory_backend.executable, Some(executable));
assert_eq!(config.memory_backend.brain, Some(brain));
assert!(config.warnings.is_empty());
}
#[test]
fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
for key in ["memory.axel.executable", "memory.axel.brain"] {
for path in ["relative/path", "~/brain", ""] {
for content in [
format!("memory.backend = axel\n{key} = {path}\n"),
format!("{key} = {path}\nmemory.backend = legacy\n"),
] {
let config = super::load_config_from_str(&content);
assert_eq!(
config.memory_backend.kind,
super::MemoryBackendKind::Unavailable
);
assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
}
}
}
}
#[test]
fn memory_config_surface_is_closed_by_construction() {
let _closed_surface = super::MemoryConfig {
default_mode: "off".to_string(),
default_mode_confirmed: false,
recall_max_records: 8,
recall_max_tokens: 4096,
recall_timeout_ms: 150,
capture_tools: "summary_only".to_string(),
capture_assistant: true,
capture_user: true,
auto_consolidate: "off".to_string(),
local_embeddings: "off".to_string(),
gliner: "off".to_string(),
};
}
use super::*;
use serial_test::serial;
#[test]
fn test_levenshtein_basics() {
assert_eq!(levenshtein("model", "model"), 0);
assert_eq!(levenshtein("modle", "model"), 2);
assert_eq!(levenshtein("them", "theme"), 1);
}
#[test]
fn test_did_you_mean_close_typos() {
assert_eq!(did_you_mean("modle"), Some("model"));
assert_eq!(did_you_mean("them"), Some("theme"));
assert_eq!(did_you_mean("thinkng"), Some("thinking"));
assert_eq!(did_you_mean("completely_unrelated_key"), None);
}
#[test]
#[serial]
fn test_config_warnings_unknown_key_and_bad_values() {
let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
let dir = home.join(".synaps-cli");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config"), "modle = claude-opus-4-6\nthinking = hgih\nbash_timeout = 0\nknowledge.jawz_notes = ~/Jawz/notes\ncustom.plugin.key = 42\n").unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
assert!(
!config.warnings.iter().any(|w| w.contains("knowledge")),
"{:?}",
config.warnings
);
assert!(
config
.warnings
.iter()
.any(|w| w.contains("did you mean 'model'")),
"{:?}",
config.warnings
);
assert!(
config.warnings.iter().any(|w| w.contains("thinking")),
"{:?}",
config.warnings
);
assert!(
config.warnings.iter().any(|w| w.contains("below minimum")),
"{:?}",
config.warnings
);
assert_eq!(config.bash_timeout, 30);
assert_eq!(config.thinking_budget, None);
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn test_cache_keepwarm_is_off_by_default() {
assert_eq!(SynapsConfig::default().cache_keepwarm, CacheKeepWarm::Off);
assert_eq!(load_config_from_str("").cache_keepwarm, CacheKeepWarm::Off);
}
#[test]
fn test_cache_keepwarm_parse_table() {
for (raw, want) in [
("off", Some(CacheKeepWarm::Off)),
("OFF", Some(CacheKeepWarm::Off)),
("0h", Some(CacheKeepWarm::Off)),
("auto", Some(CacheKeepWarm::Auto)),
(" Auto ", Some(CacheKeepWarm::Auto)),
("1h", Some(CacheKeepWarm::Hours(1))),
("12H", Some(CacheKeepWarm::Hours(12))),
("48h", Some(CacheKeepWarm::Hours(MAX_CACHE_KEEPWARM_HOURS))),
("49h", None),
("12", None),
("h", None),
("-1h", None),
("lots", None),
] {
assert_eq!(CacheKeepWarm::parse(raw), want, "{raw:?}");
}
for mode in [CacheKeepWarm::Off, CacheKeepWarm::Auto, CacheKeepWarm::Hours(6)] {
assert_eq!(CacheKeepWarm::parse(&mode.to_string()), Some(mode));
}
}
#[test]
fn test_cache_keepwarm_config_key() {
let c = load_config_from_str("cache_keepwarm = auto\ncache_ttl = 5m\n");
assert_eq!(c.cache_keepwarm, CacheKeepWarm::Auto);
assert_eq!(c.cache_ttl, CacheTtl::FiveMinutes);
assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
let c = load_config_from_str("cache_keepwarm = 6h\n");
assert_eq!(c.cache_keepwarm, CacheKeepWarm::Hours(6));
assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "default auto cap");
let c = load_config_from_str("cache_keepwarm_auto_max_hours = 8\n");
assert_eq!(c.cache_keepwarm_auto_max_hours, 8);
assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
let c = load_config_from_str("cache_keepwarm_auto_max_hours = 12h\n");
assert_eq!(c.cache_keepwarm_auto_max_hours, 12);
for bad in ["0", "49", "lots"] {
let c = load_config_from_str(&format!("cache_keepwarm_auto_max_hours = {bad}\n"));
assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "{bad}");
assert!(
c.warnings.iter().any(|w| w.contains("auto_max_hours")),
"{bad}"
);
}
let c = load_config_from_str("cache_keepwarm = forever\n");
assert_eq!(c.cache_keepwarm, CacheKeepWarm::Off, "garbage keeps keep-warm off");
assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm")));
}
#[test]
fn test_cache_ttl_parse_table() {
assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
assert_eq!(CacheTtl::parse("2h"), None);
assert_eq!(CacheTtl::parse(""), None);
assert_eq!(CacheTtl::parse("forever"), None);
}
#[test]
fn test_cache_ttl_default_is_five_minutes() {
assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
}
#[test]
fn test_max_fps_default_is_60() {
assert_eq!(SynapsConfig::default().max_fps, 60);
}
#[test]
fn test_theme_transition_parse_table() {
use ThemeTransitionMode as M;
assert_eq!(M::parse("on"), Some(M::On));
assert_eq!(M::parse("ON"), Some(M::On));
assert_eq!(M::parse("off"), Some(M::Off));
assert_eq!(M::parse("Off"), Some(M::Off));
assert_eq!(M::parse("500"), Some(M::Ms(500)));
assert_eq!(M::parse("0"), Some(M::Ms(0)));
assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
assert_eq!(M::parse("fast"), None);
assert_eq!(M::parse(""), None);
assert_eq!(M::parse("-5"), None);
assert_eq!(M::parse("1.5"), None);
}
#[test]
fn test_theme_transition_duration_ms() {
use ThemeTransitionMode as M;
assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
assert_eq!(M::DEFAULT_MS, 350);
assert_eq!(M::Off.duration_ms(), 0);
assert_eq!(M::Ms(120).duration_ms(), 120);
assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
}
#[test]
fn test_theme_transition_config_key_parses_and_defaults() {
use ThemeTransitionMode as M;
assert_eq!(SynapsConfig::default().theme_transition, M::On);
assert_eq!(
super::load_config_from_str("theme_transition = off\n").theme_transition,
M::Off
);
assert_eq!(
super::load_config_from_str("theme_transition = 750\n").theme_transition,
M::Ms(750)
);
let bad = super::load_config_from_str("theme_transition = warp\n");
assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
assert!(
bad.warnings.iter().any(|w| w.contains("theme_transition")),
"{:?}",
bad.warnings
);
}
#[test]
#[serial]
fn test_max_fps_config_parse_and_validation() {
let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
let dir = home.join(".synaps-cli");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.max_fps, 144);
assert!(
config.warnings.is_empty(),
"warnings: {:?}",
config.warnings
);
});
std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.max_fps, 60);
assert!(
config.warnings.iter().any(|w| w.contains("max_fps")),
"warnings: {:?}",
config.warnings
);
});
std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.max_fps, 60);
assert!(
config.warnings.iter().any(|w| w.contains("max_fps")),
"warnings: {:?}",
config.warnings
);
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn test_cache_ttl_config_parse_and_garbage_warning() {
let home =
std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
let dir = home.join(".synaps-cli");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
assert!(
config.warnings.is_empty(),
"warnings: {:?}",
config.warnings
);
});
std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
assert!(
config.warnings.iter().any(|w| w.contains("cache_ttl")),
"warnings: {:?}",
config.warnings
);
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn test_parse_thinking_budget() {
use crate::core::reasoning::ThinkingSpec;
assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
assert_eq!(
ThinkingSpec::parse("medium").unwrap().to_budget(),
Some(4096)
);
assert_eq!(
ThinkingSpec::parse("high").unwrap().to_budget(),
Some(16384)
);
assert_eq!(
ThinkingSpec::parse("xhigh").unwrap().to_budget(),
Some(32768)
);
assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
let config = load_config_from_str("thinking = 8192\n");
assert_eq!(config.thinking_level, None);
assert_eq!(config.thinking_budget, Some(8192));
assert_eq!(ThinkingSpec::parse("invalid"), None);
}
#[test]
fn test_base_dir() {
let path = base_dir();
assert!(path.to_string_lossy().ends_with(".synaps-cli"));
}
#[test]
fn test_resolve_system_prompt_explicit() {
let result = resolve_system_prompt(Some("test prompt"));
assert_eq!(result, "test prompt");
}
#[test]
fn test_resolve_system_prompt_none() {
let result = resolve_system_prompt(None);
assert!(result.contains("helpful AI agent"));
}
#[test]
fn test_synaps_config_default() {
let config = SynapsConfig::default();
assert_eq!(config.model, None);
assert_eq!(config.thinking_budget, None);
assert_eq!(config.max_tool_output, 30000);
assert_eq!(config.bash_timeout, 30);
assert_eq!(config.bash_max_timeout, 300);
assert_eq!(config.subagent_timeout, 300);
assert_eq!(config.api_retries, 3);
assert_eq!(config.theme, None);
assert!(config.disabled_plugins.is_empty());
assert!(config.favorite_models.is_empty());
assert!(config.disabled_skills.is_empty());
assert!(!config.progressive_tool_disclosure);
assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
assert_eq!(config.shell.max_sessions, 5);
assert_eq!(config.shell.idle_timeout.as_secs(), 600);
assert!(config.server.allowed_origins.is_empty());
assert_eq!(config.server.token, None);
assert!(!config.server.auto_approve_confirms);
assert_eq!(config.server.max_message_size, None);
assert!(config.bridge.uds_path.is_none());
assert!(!config.bridge.heartbeat_mirror);
assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
}
#[test]
#[serial]
fn test_load_config_tools_activation_confirm_modes() {
for (raw, expected, warns) in [
("prompt", ActivationConfirm::Prompt, false),
("deny", ActivationConfirm::Deny, false),
("AUTO", ActivationConfirm::Auto, false),
("garbage", ActivationConfirm::Auto, true),
] {
let home = make_test_home(&format!("activation-confirm-{raw}"));
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.tools_activation_confirm, expected, "{raw}");
assert_eq!(
config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
warns,
"{raw}: {:?}",
config.warnings
);
});
let _ = std::fs::remove_dir_all(&home);
}
}
#[test]
#[serial]
fn test_load_config_progressive_tool_disclosure_is_opt_in() {
let home = make_test_home("progressive-tool-disclosure");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
with_home(&home, || {
let config = load_config();
assert!(config.progressive_tool_disclosure);
assert!(config.warnings.is_empty());
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn test_load_config_bridge_keys() {
let home = make_test_home("bridge-keys");
let cfg = home.join(".synaps-cli/config");
std::fs::write(
&cfg,
"\
bridge.uds_path = /tmp/some/control.sock\n\
bridge.heartbeat_mirror = true\n\
bridge.heartbeat_timeout_ms = 750\n\
",
)
.unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(
config.bridge.uds_path,
Some(std::path::PathBuf::from("/tmp/some/control.sock")),
);
assert!(config.bridge.heartbeat_mirror);
assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
assert_eq!(
config.bridge.resolved_uds_path(),
std::path::PathBuf::from("/tmp/some/control.sock"),
);
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn test_bridge_config_defaults() {
let cfg = BridgeConfig::default();
assert!(cfg.uds_path.is_none());
assert!(!cfg.heartbeat_mirror);
assert_eq!(cfg.heartbeat_timeout_ms, 250);
let resolved = cfg.resolved_uds_path();
assert!(resolved.ends_with("bridge/control.sock"));
}
#[test]
#[serial]
fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
let home = make_test_home("bridge-default-off");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
with_home(&home, || {
let config = load_config();
assert!(!config.bridge.heartbeat_mirror);
assert!(config.bridge.uds_path.is_none());
assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn compaction_disclosure_keys_parse_with_typed_warnings() {
let config = load_config_from_str(
"compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
);
assert_eq!(
config.compaction_mode,
crate::core::compaction::CompactionMode::LocalOnly
);
assert_eq!(
config.compaction_exclude,
vec![
crate::core::compaction::ContentClass::Thinking,
crate::core::compaction::ContentClass::ToolResults,
]
);
assert!(
config.warnings.iter().any(|w| w.contains("bogus")),
"unknown class must warn: {:?}",
config.warnings
);
let defaults = load_config_from_str("");
assert_eq!(
defaults.compaction_mode,
crate::core::compaction::CompactionMode::Remote
);
assert!(defaults.compaction_exclude.is_empty());
let bad_mode = load_config_from_str("compaction_mode = cloud\n");
assert_eq!(
bad_mode.compaction_mode,
crate::core::compaction::CompactionMode::Remote
);
assert!(bad_mode
.warnings
.iter()
.any(|w| w.contains("compaction_mode")));
}
#[test]
#[serial]
fn test_load_config_server_keys() {
let home = make_test_home("server-keys");
let cfg = home.join(".synaps-cli/config");
std::fs::write(
&cfg,
"\
server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
server.token = my-secret-token\n\
server.auto_approve_confirms = true\n\
server.max_message_size = 65536\n\
context_window = 200k\n\
",
)
.unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(
config.server.allowed_origins,
vec![
"http://localhost:3000".to_string(),
"http://localhost:5193".to_string(),
]
);
assert_eq!(config.server.token, Some("my-secret-token".to_string()));
assert!(config.server.auto_approve_confirms);
assert_eq!(config.server.max_message_size, Some(65536));
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn test_server_max_message_size_derived_from_context_window() {
let home = make_test_home("server-derive");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "context_window = 200k\n").unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(config.server.max_message_size, Some(800_000));
});
let _ = std::fs::remove_dir_all(&home);
}
fn make_test_home(subdir: &str) -> std::path::PathBuf {
let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
dir
}
fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
let original = std::env::var("HOME").ok();
let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
std::env::set_var("HOME", home);
std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
f();
if let Some(h) = original {
std::env::set_var("HOME", h);
} else {
std::env::remove_var("HOME");
}
if let Some(b) = original_base {
std::env::set_var("SYNAPS_BASE_DIR", b);
} else {
std::env::remove_var("SYNAPS_BASE_DIR");
}
}
#[test]
#[serial]
fn write_config_value_replaces_existing_key() {
let home = make_test_home("replace");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
with_home(&home, || {
write_config_value("model", "claude-sonnet-4-6").unwrap();
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("model = claude-sonnet-4-6"));
assert!(contents.contains("thinking = low"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn write_config_value_appends_when_missing() {
let home = make_test_home("append");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
with_home(&home, || {
write_config_value("theme", "dracula").unwrap();
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("model = claude-opus-4-6"));
assert!(contents.contains("theme = dracula"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn write_config_value_concurrent_writers_lose_nothing() {
let home = make_test_home("concurrent");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "# keep\n").unwrap();
with_home(&home, || {
let a = std::thread::spawn(|| {
for i in 0..200 {
write_config_value("a_key", &i.to_string()).unwrap();
}
});
let b = std::thread::spawn(|| {
for i in 0..200 {
write_config_value("b_key", &i.to_string()).unwrap();
}
});
a.join().unwrap();
b.join().unwrap();
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("# keep"), "{contents}");
assert!(contents.contains("a_key = 199"), "{contents}");
assert!(contents.contains("b_key = 199"), "{contents}");
assert_eq!(contents.matches("a_key").count(), 1);
assert_eq!(contents.matches("b_key").count(), 1);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let lock = home.join(".synaps-cli/config.lock");
let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn config_lock_kill_switch_still_writes() {
let home = make_test_home("lock-off");
let cfg = home.join(".synaps-cli/config");
std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
with_home(&home, || {
write_config_value("model", "claude-sonnet-4-6").unwrap();
});
std::env::remove_var("SYNAPS_CONFIG_LOCK");
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("model = claude-sonnet-4-6"));
assert!(!home.join(".synaps-cli/config.lock").exists());
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn add_favorite_model_end_to_end_does_not_deadlock() {
let home = make_test_home("favorite-lock");
let cfg = home.join(".synaps-cli/config");
with_home(&home, || {
add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
add_favorite_model("openai/gpt-5").unwrap();
remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn write_config_value_preserves_comments() {
let home = make_test_home("comments");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
with_home(&home, || {
write_config_value("model", "claude-sonnet-4-6").unwrap();
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("# user comment"));
assert!(contents.contains("# another"));
assert!(contents.contains("model = claude-sonnet-4-6"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn write_config_value_preserves_unknown_keys() {
let home = make_test_home("unknown");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
with_home(&home, || {
write_config_value("model", "claude-sonnet-4-6").unwrap();
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("custom_thing = 42"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn write_config_value_creates_file_if_absent() {
let home = make_test_home("create");
let cfg = home.join(".synaps-cli/config");
assert!(!cfg.exists());
with_home(&home, || {
write_config_value("model", "claude-sonnet-4-6").unwrap();
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("model = claude-sonnet-4-6"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn load_config_parses_theme_key() {
let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
let config = load_config();
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
assert_eq!(config.theme.as_deref(), Some("dracula"));
}
#[test]
#[serial]
fn test_load_config_disable_lists() {
let test_dir =
std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
let _ = std::fs::create_dir_all(&test_dir);
let config_path = test_dir.join("config");
let config_content = r#"
# Test config with disable lists
favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
disabled_plugins = foo, bar
disabled_skills = baz, plug:qual
"#;
std::fs::write(&config_path, config_content).unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
let config = load_config();
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
assert_eq!(
config.disabled_plugins,
vec!["foo".to_string(), "bar".to_string()]
);
assert_eq!(
config.favorite_models,
vec![
"claude/claude-opus-4-7".to_string(),
"groq/llama-3.3-70b-versatile".to_string(),
]
);
assert_eq!(
config.disabled_skills,
vec!["baz".to_string(), "plug:qual".to_string()]
);
}
#[test]
#[serial]
fn favorite_model_helpers_round_trip_through_config_file() {
let home = make_test_home("favorite-models");
let cfg = home.join(".synaps-cli/config");
std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
with_home(&home, || {
add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
add_favorite_model("claude/claude-opus-4-7").unwrap();
add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
assert!(is_favorite_model("claude/claude-opus-4-7"));
});
let contents = std::fs::read_to_string(&cfg).unwrap();
assert!(contents.contains("model = claude-opus-4-7"));
assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn test_load_config_new_keys() {
let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
let _ = std::fs::create_dir_all(&test_dir);
let config_path = test_dir.join("config");
let config_content = r#"
# Test config with new keys
model = claude-haiku
thinking = medium
max_tool_output = 50000
bash_timeout = 45
bash_max_timeout = 600
subagent_timeout = 120
api_retries = 5
"#;
std::fs::write(&config_path, config_content).unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
let config = load_config();
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
assert_eq!(config.model, Some("claude-haiku".to_string()));
assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
assert_eq!(config.bash_timeout, 45);
assert_eq!(config.bash_max_timeout, 600);
assert_eq!(config.subagent_timeout, 120);
assert_eq!(config.api_retries, 5);
}
#[test]
fn events_auto_turn_explicit_true_values() {
for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
assert!(
cfg.events.auto_turn,
"expected true for events.auto_turn = {val}"
);
}
}
#[test]
fn events_auto_turn_explicit_false_values() {
for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
assert!(
!cfg.events.auto_turn,
"expected false for events.auto_turn = {val}"
);
}
}
#[test]
fn events_auto_turn_typo_fails_safe_false() {
let cfg = load_config_from_str("events.auto_turn = fales");
assert!(
!cfg.events.auto_turn,
"typo 'fales' must fail safe to false"
);
}
#[test]
fn events_auto_turn_cap_default_is_five() {
let cfg = load_config_from_str("");
assert_eq!(cfg.events.auto_turn_cap, 5);
assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
}
#[test]
fn events_auto_turn_cap_zero_is_unlimited() {
let cfg = load_config_from_str("events.auto_turn_cap = 0");
assert_eq!(cfg.events.auto_turn_cap, 0);
}
#[test]
fn events_auto_turn_cap_unlimited_aliases() {
for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
assert_eq!(
cfg.events.auto_turn_cap, 0,
"expected 0 for events.auto_turn_cap = {val:?}"
);
}
}
#[test]
fn events_auto_turn_cap_explicit_number() {
let cfg = load_config_from_str("events.auto_turn_cap = 12");
assert_eq!(cfg.events.auto_turn_cap, 12);
}
#[test]
fn events_auto_turn_cap_garbage_keeps_default() {
for val in ["banana", "-3", "5.5", "", "true"] {
let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
assert_eq!(
cfg.events.auto_turn_cap, 5,
"garbage {val:?} must keep default 5"
);
}
}
#[test]
fn events_auto_turn_cap_independent_of_auto_turn() {
let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
assert!(!cfg.events.auto_turn);
assert_eq!(cfg.events.auto_turn_cap, 0);
}
#[test]
fn events_auto_turn_default_is_true() {
let cfg = load_config_from_str("model = claude-haiku");
assert!(cfg.events.auto_turn, "default must be true when key absent");
}
#[test]
#[serial]
fn test_auth_config_parses_endpoint_and_token() {
let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
let dir = home.join(".synaps-cli");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("config"),
"auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
)
.unwrap();
with_home(&home, || {
let config = load_config();
assert_eq!(
config.auth.remote_endpoint.as_deref(),
Some("https://jade.jade:8181")
);
assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
assert!(
!config.warnings.iter().any(|w| w.contains("auth.")),
"{:?}",
config.warnings
);
});
let _ = std::fs::remove_dir_all(&home);
}
#[test]
#[serial]
fn test_credential_source_local_by_default() {
std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
std::env::remove_var("SYNAPS_MACHINE_TOKEN");
assert!(!AuthConfig::default().credential_source().is_remote());
}
#[test]
#[serial]
fn test_credential_source_remote_from_config() {
std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
std::env::remove_var("SYNAPS_MACHINE_TOKEN");
let auth = AuthConfig {
remote_endpoint: Some("https://b".into()),
machine_token: Some("m".into()),
};
assert_eq!(
auth.credential_source(),
crate::core::auth::CredentialSource::Remote {
endpoint: "https://b".into(),
machine_token: "m".into()
}
);
}
#[test]
#[serial]
fn test_credential_source_env_overrides_config() {
let auth = AuthConfig {
remote_endpoint: Some("https://config-host".into()),
machine_token: Some("config-tok".into()),
};
std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
let src = auth.credential_source();
std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
std::env::remove_var("SYNAPS_MACHINE_TOKEN");
assert_eq!(
src,
crate::core::auth::CredentialSource::Remote {
endpoint: "https://env-host".into(),
machine_token: "env-tok".into()
}
);
}
#[test]
fn config_parses_ultracode_as_distinct_canonical_level() {
let cfg = load_config_from_str("thinking = ultracode\n");
assert_eq!(
cfg.thinking_level,
Some(crate::reasoning::ReasoningLevel::UltraCode)
);
assert_eq!(cfg.thinking_budget, None);
for other in [
crate::reasoning::ReasoningLevel::Ultra,
crate::reasoning::ReasoningLevel::Max,
crate::reasoning::ReasoningLevel::XHigh,
] {
assert_ne!(cfg.thinking_level, Some(other));
}
}
#[test]
fn context_window_auto_is_model_default_without_warning() {
for spelling in ["auto", "AUTO", "Auto"] {
let cfg = load_config_from_str(&format!("context_window = {spelling}\n"));
assert_eq!(cfg.context_window, None, "{spelling}");
assert!(cfg.warnings.is_empty(), "{spelling}: {:?}", cfg.warnings);
}
let cfg = load_config_from_str("context_window = 1m\ncontext_window = auto\n");
assert_eq!(cfg.context_window, None);
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
assert_eq!(
load_config_from_str("context_window = 200k\n").context_window,
Some(200_000)
);
let bad = load_config_from_str("context_window = huge\n");
assert_eq!(bad.context_window, None);
assert!(bad.warnings.iter().any(|w| w.contains("context_window")));
}
#[test]
fn sidecar_toggle_key_is_a_known_key() {
let cfg = load_config_from_str("sidecar_toggle_key = F2\n");
assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
assert!(KNOWN_CONFIG_KEYS.contains(&"sidecar_toggle_key"));
assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_mode"));
assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_exclude"));
}
#[test]
fn config_content_problems_covers_family_diagnostics() {
for bad in [
"shell.max_sessions = lots",
"shell.readiness_strategy = eventually",
"events.auto_turn = maybe",
"events.auto_turn_cap = many",
"turn_budget.worker.max_cost_usd = cheap",
"turn_budget.nobody.max_tool_calls = 3",
"memory.capture_tools = everything",
"memory.recall_max_records = -1",
"memory.user_scope = yes",
"cache_ttl = forever",
] {
let problems = config_content_problems(bad);
assert!(!problems.is_empty(), "{bad} should be reported");
}
for good in [
"shell.max_sessions = 8",
"shell.readiness_strategy = prompt",
"events.auto_turn = off",
"events.auto_turn_cap = unlimited",
"turn_budget.worker.max_cost_usd = 2.5",
"memory.capture_tools = off",
"memory.recall_max_records = 4",
"memory.user_scope = true",
"cache_ttl = hybrid",
] {
let problems = config_content_problems(good);
assert!(problems.is_empty(), "{good}: {problems:?}");
}
let cfg = load_config_from_str("shell.max_sessions = 8\nevents.auto_turn = maybe\n");
assert_eq!(cfg.shell.max_sessions, 8);
assert!(!cfg.events.auto_turn, "unrecognised auto_turn still fails safe to false");
}
#[test]
fn family_config_keys_are_real_parser_keys() {
let sample = |key: &str| -> &'static str {
match key {
"shell.readiness_strategy" => "hybrid",
"memory.backend" => "legacy",
"memory.user_scope" | "memory.default_mode_confirmed" => "true",
"memory.default_mode" => "off",
"memory.capture_tools" => "summary_only",
"memory.capture_assistant" | "memory.capture_user" => "on",
"memory.auto_consolidate" | "memory.local_embeddings" | "memory.gliner" => "off",
"memory.axel.executable" | "memory.axel.brain" => "/opt/axel",
"events.auto_turn" => "on",
"server.allowed_origins" | "server.token" => "x",
"server.auto_approve_confirms" | "bridge.heartbeat_mirror" => "false",
"bridge.uds_path" => "/tmp/x.sock",
"auth.remote_endpoint" | "auth.machine_token" => "x",
_ => "7",
}
};
for key in FAMILY_CONFIG_KEYS.iter().filter(|k| !k.ends_with(".*")) {
assert!(!KNOWN_CONFIG_KEYS.contains(key), "{key} is listed twice");
let content = format!("{key} = {}\n", sample(key));
let problems = config_content_problems(&content);
assert!(problems.is_empty(), "{key}: {problems:?}");
}
}
#[test]
#[serial]
fn validate_config_edit_uses_the_real_parser_and_whole_file() {
let home = make_test_home("validate-edit");
let cfg = home.join(".synaps-cli/config");
with_home(&home, || {
assert!(validate_config_edit("cache_ttl", "1h").is_ok());
assert!(validate_config_edit("cache_ttl", "2h").is_err());
assert!(validate_config_edit("shell.max_output", "big").is_err());
assert!(validate_config_edit("memory.axel.brain", "relative/brain").is_err());
assert!(validate_config_edit("memory.axel.brain", "/abs/brain.r8").is_ok());
assert!(validate_config_edit("model", "a\nb").is_err());
std::fs::write(&cfg, "context_management.rollover_tokens = 100000\n").unwrap();
assert!(validate_config_edit("context_management.pressure_tokens", "90000").is_ok());
assert!(validate_config_edit("context_management.pressure_tokens", "150000").is_err());
std::fs::write(&cfg, "subagent.max_total = 8\n").unwrap();
assert!(validate_config_edit("subagent.max_concurrent", "8").is_ok());
assert!(validate_config_edit("subagent.max_concurrent", "9").is_err());
std::fs::write(&cfg, "").unwrap();
assert!(validate_config_edit("memory.default_mode", "recall_once").is_err());
assert!(validate_config_edit("memory.default_mode", "off").is_ok());
std::fs::write(&cfg, "memory.default_mode_confirmed = true\n").unwrap();
assert!(validate_config_edit("memory.default_mode", "recall_once").is_ok());
std::fs::write(&cfg, "cache_ttl = forever\n").unwrap();
assert!(validate_config_edit("max_fps", "120").is_ok());
assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "cache_ttl = forever\n");
});
let _ = std::fs::remove_dir_all(&home);
}
}