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, 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)
}
}
#[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(Debug, Clone)]
pub struct EventsConfig {
pub auto_turn: bool,
}
impl Default for EventsConfig {
fn default() -> Self {
Self { auto_turn: true }
}
}
fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) {
if key == "events.auto_turn" {
let normalised = val.trim().to_lowercase();
cfg.auto_turn = match normalised.as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
other => {
eprintln!(
"warning: config: unrecognised value for events.auto_turn = {:?}; \
expected true/false/yes/no/on/off/1/0 — defaulting to false",
other
);
false
}
};
} }
#[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) {
let Some(rest) = key.strip_prefix("turn_budget.") else {
return;
};
let Some((role, field)) = rest.split_once('.') else {
eprintln!("Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)");
return;
};
let overrides = match role {
"foreground" => &mut budgets.foreground,
"autonomous" => &mut budgets.autonomous,
"worker" => &mut budgets.worker,
other => {
eprintln!("Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|worker)");
return;
}
};
macro_rules! set {
($slot:expr, $ty:ty) => {
match val.parse::<$ty>() {
Ok(parsed) => $slot = Some(parsed),
Err(_) => eprintln!("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 => eprintln!("Warning: unknown turn_budget field '{other}'"),
}
}
#[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) {
let Some(field) = key.strip_prefix("memory.") else {
return;
};
fn parse_bool(slot: &mut bool, key: &str, val: &str) {
match val {
"true" | "1" | "yes" | "on" => *slot = true,
"false" | "0" | "no" | "off" => *slot = false,
other => eprintln!(
"Warning: invalid value for {key}: '{other}', expected true/false — using default"
),
}
}
fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) {
if allowed.contains(&val) {
*slot = val.to_string();
} else {
eprintln!(
"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,
Err(_) => eprintln!("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 => eprintln!("Warning: unknown memory field '{other}'"),
}
}
#[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 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 max_fps: u32,
pub scroll_lines: Option<u16>,
pub theme: Option<String>,
pub tui_background_opaque: 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 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 warnings: Vec<String>,
}
impl Default for SynapsConfig {
fn default() -> Self {
Self {
model: None,
thinking_budget: None,
thinking_level: None,
context_window: None,
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(),
max_fps: 60,
scroll_lines: None,
theme: None,
tui_background_opaque: true,
agent_name: None,
identity: None,
disabled_plugins: Vec::new(),
favorite_models: Vec::new(),
disabled_skills: Vec::new(),
progressive_tool_disclosure: false,
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(),
warnings: Vec::new(),
}
}
}
const KNOWN_CONFIG_KEYS: &[&str] = &[
"model",
"thinking",
"compaction_model",
"context_window",
"max_tool_output",
"bash_timeout",
"bash_max_timeout",
"subagent_timeout",
"api_retries",
"refusal_retries",
"telemetry",
"cache_diagnostics",
"cache_ttl",
"max_fps",
"scroll_lines",
"theme",
"tui_background_opaque",
"agent_name",
"identity",
"disabled_plugins",
"favorite_models",
"disabled_skills",
"disabled_tools",
"progressive_tool_disclosure",
"session_persistence",
];
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(key: &str, values: &[String]) -> std::io::Result<()> {
write_config_value(key, &values.join(", "))
}
fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) {
match key {
"shell.max_sessions" => {
if let Ok(sessions) = val.parse::<usize>() {
shell_config.max_sessions = sessions;
} else {
eprintln!(
"Warning: invalid value for shell.max_sessions: '{}', using default",
val
);
}
}
"shell.idle_timeout" => {
if let Ok(timeout) = val.parse::<u64>() {
shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
} else {
eprintln!(
"Warning: invalid value for shell.idle_timeout: '{}', using default",
val
);
}
}
"shell.readiness_timeout_ms" => {
if let Ok(timeout) = val.parse::<u64>() {
shell_config.readiness_timeout_ms = timeout;
} else {
eprintln!(
"Warning: invalid value for shell.readiness_timeout_ms: '{}', using default",
val
);
}
}
"shell.max_readiness_timeout_ms" => {
if let Ok(timeout) = val.parse::<u64>() {
shell_config.max_readiness_timeout_ms = timeout;
} else {
eprintln!("Warning: invalid value for shell.max_readiness_timeout_ms: '{}', using default", val);
}
}
"shell.default_rows" => {
if let Ok(rows) = val.parse::<u16>() {
shell_config.default_rows = rows;
} else {
eprintln!(
"Warning: invalid value for shell.default_rows: '{}', using default",
val
);
}
}
"shell.default_cols" => {
if let Ok(cols) = val.parse::<u16>() {
shell_config.default_cols = cols;
} else {
eprintln!(
"Warning: invalid value for shell.default_cols: '{}', using default",
val
);
}
}
"shell.readiness_strategy" => {
let val_lower = val.to_lowercase();
match val_lower.as_str() {
"timeout" | "prompt" | "hybrid" => {
shell_config.readiness_strategy = val.to_string();
}
_ => {
eprintln!(
"Warning: invalid value for shell.readiness_strategy: '{}', using default",
val
);
}
}
}
"shell.max_output" => {
if let Ok(max_output) = val.parse::<usize>() {
shell_config.max_output = max_output;
} else {
eprintln!(
"Warning: invalid value for shell.max_output: '{}', using default",
val
);
}
}
_ => {
}
}
}
#[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 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) {
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" => {
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, 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"
)),
},
"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()),
"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"
}
)),
},
"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");
}
"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);
}
_ => {
if key.starts_with("shell.") {
parse_shell_config_key(&mut config.shell, key, val);
} 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.") {
parse_events_config_key(&mut config.events, key, val);
} else if key.starts_with("turn_budget.") {
parse_turn_budget_config_key(&mut config.turn_budgets, key, val);
} else if key.starts_with("memory.") {
parse_memory_config_key(&mut config.memory, key, val);
} 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")),
}
}
}
}
}
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
}
pub fn write_config_value(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 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');
}
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(())
}
pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
let trimmed = id.trim();
if trimmed.is_empty() {
return Ok(());
}
let mut values = load_config().favorite_models;
if !values.iter().any(|v| v == trimmed) {
values.push(trimmed.to_string());
values.sort();
}
write_comma_list("favorite_models", &values)
}
pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
let mut values = load_config().favorite_models;
values.retain(|v| v != id.trim());
write_comma_list("favorite_models", &values)
}
pub fn is_favorite_model(id: &str) -> bool {
load_config().favorite_models.iter().any(|v| v == id.trim())
}
pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
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 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_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_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]
#[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.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_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();
std::env::set_var("HOME", home);
f();
if let Some(h) = original {
std::env::set_var("HOME", h);
} else {
std::env::remove_var("HOME");
}
}
#[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_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_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));
}
}
}