pub mod instructions;
pub mod memory;
pub mod prompt_sections;
pub mod provider;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use atomcode_telemetry::TelemetryConfig;
use provider::ProviderConfig;
#[allow(clippy::needless_raw_string_hashes)]
pub const WINDOWS_RULES: &str = r##"\
## WINDOWS PLATFORM RULES:
- Bash runs via cmd.exe, NOT WSL. Use Windows syntax: dir (not ls), where (not which), type (not cat).
- Path separators: use \\ in commands. Example: cd src\\components
- Install tools: use winget, choco, or direct download. NOT apt/brew.
- Check tools: where <tool_name> (not which).
- PowerShell: for complex scripts, use powershell -Command "..."
- Virtual environments: check for Scripts\\ subdirectory (not bin/)"##;
pub const MACOS_RULES: &str = "";
pub const LINUX_RULES: &str = "";
pub fn platform_rules() -> &'static str {
if cfg!(target_os = "windows") {
WINDOWS_RULES
} else if cfg!(target_os = "macos") {
MACOS_RULES
} else {
LINUX_RULES
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SubAgentConfig {
pub enabled: bool,
pub initial_turns: usize,
pub max_turns: usize,
pub max_concurrent: usize,
pub timeout_secs: u64,
}
impl Default for SubAgentConfig {
fn default() -> Self {
Self {
enabled: true,
initial_turns: 4,
max_turns: 12,
max_concurrent: 3,
timeout_secs: 300,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub default_provider: String,
pub default_workdir: Option<String>,
pub providers: HashMap<String, ProviderConfig>,
#[serde(default, skip_serializing)]
pub datalog: DatalogConfig,
#[serde(default, skip_serializing)]
pub notifications: NotificationConfig,
#[serde(default = "default_true")]
pub auto_update: bool,
#[serde(default, skip_serializing)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub lsp: LspConfig,
#[serde(default)]
pub auto_commit: bool,
#[serde(default)]
pub subagent: SubAgentConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vision_preprocessor_provider: Option<String>,
#[serde(default)]
pub language: Option<crate::locale::Locale>,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub plugin: PluginConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginConfig {
#[serde(default = "default_true")]
pub auto_install_default_skills: bool,
#[serde(default = "default_true")]
pub auto_update_marketplaces: bool,
}
impl Default for PluginConfig {
fn default() -> Self {
Self {
auto_install_default_skills: true,
auto_update_marketplaces: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default)]
pub theme: UiTheme,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum UiTheme {
#[default]
Auto,
Dark,
Light,
}
impl Config {
pub fn can_handle_attached_images(&self) -> bool {
let active_accepts = self
.providers
.get(&self.default_provider)
.map(|p| p.accepts_images())
.unwrap_or(false);
if active_accepts {
return true;
}
let vp_key = match self.vision_preprocessor_provider.as_deref() {
Some(k) if !k.is_empty() => k,
_ => return false,
};
self.providers.contains_key(vp_key)
}
}
impl Default for Config {
fn default() -> Self {
Self {
default_provider: String::new(),
default_workdir: None,
providers: HashMap::new(),
datalog: Default::default(),
notifications: Default::default(),
auto_update: true,
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: UiConfig::default(),
plugin: PluginConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatalogConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_notification_min_duration_secs")]
pub min_duration_secs: u64,
#[serde(default = "default_true")]
pub terminal: bool,
#[serde(default = "default_true")]
pub system: bool,
#[serde(default = "default_true")]
pub bell: bool,
#[serde(default = "default_true")]
pub background_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LspConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub auto_detect: bool,
#[serde(default)]
pub servers: std::collections::HashMap<String, crate::lsp::registry::LspServerConfig>,
#[serde(default = "default_diagnostics_settle_delay_ms")]
pub diagnostics_settle_delay_ms: u64,
}
fn default_diagnostics_settle_delay_ms() -> u64 {
150
}
impl Default for LspConfig {
fn default() -> Self {
Self {
enabled: false,
auto_detect: false,
servers: Default::default(),
diagnostics_settle_delay_ms: default_diagnostics_settle_delay_ms(),
}
}
}
fn migrate_legacy_lsp_default(cfg: &mut Config) {
let looks_auto_written = cfg.lsp.enabled
&& cfg.lsp.auto_detect
&& cfg.lsp.diagnostics_settle_delay_ms == 150
&& cfg.lsp.servers.is_empty();
if looks_auto_written {
cfg.lsp = LspConfig::default();
}
}
fn default_true() -> bool {
true
}
fn default_notification_min_duration_secs() -> u64 {
8
}
impl Default for DatalogConfig {
fn default() -> Self {
Self {
enabled: true,
dir: Some("~/.atomcode/datalog".to_string()),
}
}
}
impl Default for NotificationConfig {
fn default() -> Self {
Self {
enabled: true,
min_duration_secs: default_notification_min_duration_secs(),
terminal: true,
system: true,
bell: true,
background_only: true,
}
}
}
fn render_datalog_section(cfg: &DatalogConfig) -> String {
let mut out = String::new();
out.push_str("\n# Per-turn datalog. Each turn writes a markdown summary; each LLM\n");
out.push_str("# round writes a JSON request/response pair under `<dir>/<project>/llm/`.\n");
out.push_str("# A per-project subdirectory is always appended under `dir` so multiple\n");
out.push_str("# projects never share a bucket.\n");
out.push_str("# - enabled = false -> disable logging entirely\n");
out.push_str("# - dir = \"~/.atomcode/datalog\" -> default (follows $HOME, ignores /cd)\n");
out.push_str("# - dir = \"/abs/path\" -> absolute, fixed (unaffected by /cd)\n");
out.push_str("# - dir = \"rel/path\" -> joined with current working_dir, follows /cd\n");
out.push_str("[datalog]\n");
out.push_str(&format!("enabled = {}\n", cfg.enabled));
let dir_value = cfg.dir.as_deref().unwrap_or("~/.atomcode/datalog");
let escaped = dir_value.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(&format!("dir = \"{}\"\n", escaped));
out
}
fn render_notifications_section(cfg: &NotificationConfig) -> String {
let mut out = String::new();
out.push_str("\n# Long-running task completion notifications.\n");
out.push_str("# Strategy: terminal-native notifications first (kitty / WezTerm / iTerm2),\n");
out.push_str(
"# then OS-native fallback when available (macOS osascript, Linux notify-send).\n",
);
out.push_str("# Windows mainly relies on BEL + terminal attention/taskbar flash.\n");
out.push_str("# `background_only` is best-effort: focus-aware terminal protocols honor it,\n");
out.push_str("# while some OS fallbacks may still notify even if AtomCode is focused.\n");
out.push_str("[notifications]\n");
out.push_str(&format!("enabled = {}\n", cfg.enabled));
out.push_str(&format!("min_duration_secs = {}\n", cfg.min_duration_secs));
out.push_str(&format!("terminal = {}\n", cfg.terminal));
out.push_str(&format!("system = {}\n", cfg.system));
out.push_str(&format!("bell = {}\n", cfg.bell));
out.push_str(&format!("background_only = {}\n", cfg.background_only));
out
}
fn render_telemetry_section(cfg: &TelemetryConfig) -> String {
if cfg.enabled.is_none() && cfg.endpoint.is_none() {
return String::new();
}
let mut out = String::new();
out.push_str("\n# Anonymous telemetry. Omit `enabled` for the default enabled behavior.\n");
out.push_str("# Set `enabled = false` to opt out persistently.\n");
out.push_str("[telemetry]\n");
if let Some(enabled) = cfg.enabled {
out.push_str(&format!("enabled = {}\n", enabled));
}
if let Some(endpoint) = cfg.endpoint.as_deref() {
let escaped = endpoint.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(&format!("endpoint = \"{}\"\n", escaped));
}
out
}
fn render_instructions_section() -> String {
let mut out = String::new();
out.push_str("\n# Project instructions — customize AI behavior via Markdown files.\n");
out.push_str("# AtomCode loads instructions from three levels (low → high priority):\n");
out.push_str("#\n");
out.push_str("# 1. ~/.atomcode/ATOMCODE.md (global — your personal defaults)\n");
out.push_str(
"# 2. <project>/.atomcode.md (project — team-shared, commit to git)\n",
);
out.push_str("# or <project>/ATOMCODE.md\n");
out.push_str("# or <project>/CLAUDE.md / claude.md (Claude Code compat)\n");
out.push_str(
"# 3. <project>/.atomcode.user.md (user — personal per-project, .gitignore)\n",
);
out.push_str("#\n");
out.push_str("# Higher priority files appear later in the prompt (recency effect).\n");
out.push_str(
"# Use /status to see which files are loaded. Use /init to generate a template.\n",
);
out.push_str("#\n");
out.push_str("# Example ~/.atomcode/ATOMCODE.md:\n");
out.push_str("# ## Global Preferences\n");
out.push_str("# - Reply in Chinese\n");
out.push_str("# - Don't add AI co-author tags to commits\n");
out.push_str("#\n");
out.push_str("# Example <project>/.atomcode.md:\n");
out.push_str("# ## Project Rules\n");
out.push_str("# - This is a Rust workspace with 5 crates\n");
out.push_str("# - Use anyhow::Result for error handling\n");
out.push_str("# - All public APIs must have doc comments\n");
out
}
fn render_hooks_json_section() -> String {
let mut out = String::new();
out.push_str("\n# Lifecycle hooks — configure in separate JSON files:\n");
out.push_str("# ~/.atomcode/hooks.json (global hooks)\n");
out.push_str("# <project>/.hooks.json (project hooks, override global by name)\n");
out.push_str("#\n");
out.push_str("# Example hooks.json:\n");
out.push_str("# {\n");
out.push_str("# \"hooks\": {\n");
out.push_str("# \"audit-all\": {\n");
out.push_str("# \"event\": \"pre_tool_use\",\n");
out.push_str("# \"command\": \"echo \\\"$(date) $ATOMCODE_TOOL_NAME\\\" >> ~/.atomcode/audit.log\"\n");
out.push_str("# },\n");
out.push_str("# \"block-rm\": {\n");
out.push_str("# \"event\": \"pre_tool_use\",\n");
out.push_str("# \"matcher\": \"bash\",\n");
out.push_str("# \"command\": \"your-safety-check.sh\",\n");
out.push_str("# \"timeout_ms\": 5000\n");
out.push_str("# }\n");
out.push_str("# }\n");
out.push_str("# }\n");
out.push_str("#\n");
out.push_str("# Events: pre_tool_use, post_tool_use, session_start, session_end\n");
out.push_str("# Env vars: ATOMCODE_HOOK_EVENT, ATOMCODE_TOOL_NAME, ATOMCODE_HOOK_CONTEXT\n");
out.push_str("# PreToolUse stdout: {\"action\":\"allow\"} or {\"action\":\"block\",\"reason\":\"...\"}\n");
out
}
impl Config {
pub fn default_context_window(&self) -> usize {
self.providers
.get(&self.default_provider)
.map(|p| p.context_window)
.unwrap_or(128_000)
}
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let mut config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config: {}", path.display()))?;
migrate_legacy_lsp_default(&mut config);
Ok(config)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut persistent = self.clone();
persistent.providers.retain(|_, v| !v.ephemeral);
if !self
.providers
.get(&self.default_provider)
.map(|p| !p.ephemeral)
.unwrap_or(true)
{
if let Ok(disk) = Config::load(path) {
persistent.default_provider = disk.default_provider;
}
}
let mut content = toml::to_string_pretty(&persistent)?;
content.push_str(&render_datalog_section(&self.datalog));
content.push_str(&render_notifications_section(&self.notifications));
content.push_str(&render_telemetry_section(&self.telemetry));
content.push_str(&render_instructions_section());
content.push_str(&render_hooks_json_section());
std::fs::write(path, content)?;
Ok(())
}
pub fn active_provider(&self, override_name: Option<&str>) -> Result<&ProviderConfig> {
let name: &str = override_name
.filter(|s| !s.is_empty())
.unwrap_or(&self.default_provider);
let fallback = || {
self.providers
.keys()
.min()
.map(String::as_str)
.ok_or_else(|| {
anyhow::anyhow!("No providers configured — run /codingplan or /provider")
})
};
let name: &str = if name.is_empty() {
fallback()?
} else {
name
};
match self.providers.get(name) {
Some(p) => Ok(p),
None => {
let fallback_name = fallback()?;
Ok(self.providers.get(fallback_name).unwrap())
}
}
}
fn resolve_config_dir(env_atomcode_home: Option<String>, home: Option<PathBuf>) -> PathBuf {
if let Some(p) = env_atomcode_home {
return PathBuf::from(p);
}
home.unwrap_or_else(|| PathBuf::from(".")).join(".atomcode")
}
pub fn config_dir() -> PathBuf {
Self::resolve_config_dir(
std::env::var("ATOMCODE_HOME")
.ok()
.filter(|s| !s.is_empty()),
crate::tool::real_home_dir(),
)
}
pub fn default_path() -> PathBuf {
Self::config_dir().join("config.toml")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lsp_config_defaults_to_disabled_opt_in() {
let cfg = LspConfig::default();
assert!(!cfg.enabled, "LSP enabled must default to false");
assert!(
!cfg.auto_detect,
"LSP auto_detect must default to false even if enabled flips on"
);
}
#[test]
fn migrate_resets_auto_written_lsp_to_disabled() {
let mut cfg = blank_config_with_lsp(LspConfig {
enabled: true,
auto_detect: true,
servers: Default::default(),
diagnostics_settle_delay_ms: 150,
});
migrate_legacy_lsp_default(&mut cfg);
assert!(!cfg.lsp.enabled, "auto-written shape must reset to disabled");
assert!(!cfg.lsp.auto_detect);
}
#[test]
fn migrate_keeps_user_customised_lsp_intact() {
let mut servers = std::collections::HashMap::new();
servers.insert(
"rs".to_string(),
crate::lsp::registry::LspServerConfig {
command: "my-custom-rust-ls".to_string(),
args: vec![],
root_markers: vec![],
},
);
let mut cfg = blank_config_with_lsp(LspConfig {
enabled: true,
auto_detect: true,
servers,
diagnostics_settle_delay_ms: 150,
});
migrate_legacy_lsp_default(&mut cfg);
assert!(cfg.lsp.enabled, "custom servers means user opt-in; keep");
let mut cfg2 = blank_config_with_lsp(LspConfig {
enabled: true,
auto_detect: true,
servers: Default::default(),
diagnostics_settle_delay_ms: 500,
});
migrate_legacy_lsp_default(&mut cfg2);
assert!(cfg2.lsp.enabled, "non-default delay means user tuned; keep");
let mut cfg3 = blank_config_with_lsp(LspConfig {
enabled: true,
auto_detect: false,
servers: Default::default(),
diagnostics_settle_delay_ms: 150,
});
migrate_legacy_lsp_default(&mut cfg3);
assert!(cfg3.lsp.enabled, "auto_detect=false means user picked manual; keep");
}
#[test]
fn migrate_noop_on_already_disabled() {
let mut cfg = blank_config_with_lsp(LspConfig::default());
migrate_legacy_lsp_default(&mut cfg);
assert!(!cfg.lsp.enabled);
assert!(!cfg.lsp.auto_detect);
}
fn blank_config_with_lsp(lsp: LspConfig) -> Config {
Config {
default_provider: "x".into(),
default_workdir: None,
providers: Default::default(),
datalog: Default::default(),
auto_update: true,
notifications: Default::default(),
telemetry: Default::default(),
lsp,
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
}
}
#[test]
fn lsp_section_omitted_in_toml_yields_disabled() {
let toml_str = r#"
default_provider = "claude"
[providers.claude]
type = "claude"
api_key = "sk-ant-test"
model = "claude-opus-4-6"
"#;
let cfg: Config = toml::from_str(toml_str).expect("config parses");
assert!(!cfg.lsp.enabled, "missing [lsp] must keep LSP off");
assert!(!cfg.lsp.auto_detect);
}
#[test]
fn test_resolve_config_dir_uses_env_when_set() {
let result = Config::resolve_config_dir(
Some("/tmp/custom-atomcode-home".to_string()),
Some(PathBuf::from("/Users/foo")),
);
assert_eq!(result, PathBuf::from("/tmp/custom-atomcode-home"));
}
#[test]
fn test_resolve_config_dir_falls_back_to_home() {
let result = Config::resolve_config_dir(None, Some(PathBuf::from("/Users/foo")));
assert_eq!(result, PathBuf::from("/Users/foo/.atomcode"));
}
#[test]
fn test_resolve_config_dir_falls_back_to_dot_when_no_home() {
let result = Config::resolve_config_dir(None, None);
assert_eq!(result, PathBuf::from("./.atomcode"));
}
#[test]
fn test_parse_minimal_config() {
let toml_str = r#"
default_provider = "claude"
[providers.claude]
type = "claude"
api_key = "sk-ant-test"
model = "claude-opus-4-6"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.default_provider, "claude");
assert_eq!(config.providers.len(), 1);
let p = &config.providers["claude"];
assert_eq!(p.provider_type, "claude");
assert_eq!(p.api_key.as_deref(), Some("sk-ant-test"));
assert_eq!(p.model, "claude-opus-4-6");
}
#[test]
fn test_parse_multi_provider_config() {
let toml_str = r#"
default_provider = "openai"
[providers.claude]
type = "claude"
api_key = "sk-ant-test"
model = "claude-opus-4-6"
[providers.openai]
type = "openai"
api_key = "sk-test"
model = "gpt-4o"
base_url = "https://api.openai.com/v1"
[providers.ollama]
type = "ollama"
model = "llama3"
base_url = "http://localhost:11434"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.default_provider, "openai");
assert_eq!(config.providers.len(), 3);
assert_eq!(
config.providers["ollama"].base_url.as_deref(),
Some("http://localhost:11434")
);
assert!(config.providers["ollama"].api_key.is_none());
}
#[test]
fn test_get_active_provider_config() {
let toml_str = r#"
default_provider = "claude"
[providers.claude]
type = "claude"
api_key = "sk-ant-test"
model = "claude-opus-4-6"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let provider = config.active_provider(None).unwrap();
assert_eq!(provider.model, "claude-opus-4-6");
}
#[test]
fn render_datalog_section_default_emits_active_dir() {
let rendered = render_datalog_section(&DatalogConfig::default());
assert!(rendered.contains("[datalog]"));
assert!(rendered.contains("enabled = true"));
assert!(
rendered.contains("\ndir = \"~/.atomcode/datalog\"\n"),
"default must emit the resolved dir as a real, uncommented value: {}",
rendered
);
}
#[test]
fn render_datalog_section_unset_dir_still_shows_default() {
let cfg = DatalogConfig {
enabled: true,
dir: None,
};
let rendered = render_datalog_section(&cfg);
assert!(rendered.contains("\ndir = \"~/.atomcode/datalog\"\n"));
}
#[test]
fn render_datalog_section_with_dir_emits_real_value() {
let cfg = DatalogConfig {
enabled: false,
dir: Some("~/.atomcode/logs".to_string()),
};
let rendered = render_datalog_section(&cfg);
assert!(rendered.contains("enabled = false"));
assert!(rendered.contains("dir = \"~/.atomcode/logs\""));
}
#[test]
fn saved_config_roundtrips_datalog() {
let tmp = std::env::temp_dir().join(format!("atomcode_cfg_rt_{}.toml", std::process::id()));
let mut cfg = Config {
default_provider: "p".to_string(),
default_workdir: None,
providers: HashMap::new(),
datalog: DatalogConfig {
enabled: false,
dir: Some("/var/log/ac".to_string()),
},
notifications: NotificationConfig::default(),
auto_update: true,
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: None,
ui: Default::default(),
plugin: Default::default(),
};
cfg.providers.insert(
"p".to_string(),
ProviderConfig {
provider_type: "openai".to_string(),
api_key: Some("k".to_string()),
model: "m".to_string(),
base_url: None,
system_prompt: None,
user_agent: None,
context_window: 16000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
},
);
cfg.save(&tmp).unwrap();
let text = std::fs::read_to_string(&tmp).unwrap();
assert!(text.contains("[datalog]"));
assert!(text.contains("enabled = false"));
assert!(text.contains("dir = \"/var/log/ac\""));
let reloaded = Config::load(&tmp).unwrap();
assert!(!reloaded.datalog.enabled);
assert_eq!(reloaded.datalog.dir.as_deref(), Some("/var/log/ac"));
assert!(reloaded.notifications.enabled);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn render_notifications_section_emits_defaults() {
let rendered = render_notifications_section(&NotificationConfig::default());
assert!(rendered.contains("[notifications]"));
assert!(rendered.contains("enabled = true"));
assert!(rendered.contains("min_duration_secs = 8"));
assert!(rendered.contains("background_only = true"));
}
#[test]
fn test_override_provider() {
let toml_str = r#"
default_provider = "claude"
[providers.claude]
type = "claude"
api_key = "sk-ant-test"
model = "claude-opus-4-6"
[providers.openai]
type = "openai"
api_key = "sk-test"
model = "gpt-4o"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let provider = config.active_provider(Some("openai")).unwrap();
assert_eq!(provider.model, "gpt-4o");
}
#[test]
fn active_provider_falls_back_when_default_is_empty() {
let toml_str = r#"
default_provider = ""
[providers.zeta]
type = "openai"
api_key = "sk-z"
model = "gpt-4o"
[providers.alpha]
type = "claude"
api_key = "sk-a"
model = "claude-opus-4-6"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let provider = config.active_provider(None).unwrap();
assert_eq!(provider.model, "claude-opus-4-6");
}
#[test]
fn active_provider_ignores_empty_override() {
let toml_str = r#"
default_provider = "claude"
[providers.claude]
type = "claude"
api_key = "sk-ant-test"
model = "claude-opus-4-6"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let provider = config.active_provider(Some("")).unwrap();
assert_eq!(provider.model, "claude-opus-4-6");
}
#[test]
fn active_provider_errors_with_no_providers_and_empty_default() {
let toml_str = r#"
default_provider = ""
[providers]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let err = config.active_provider(None).unwrap_err();
assert!(
err.to_string().contains("No providers configured"),
"unexpected error: {err}"
);
}
#[test]
fn active_provider_falls_back_when_default_points_to_deleted_provider() {
let toml_str = r#"
default_provider = "AtomGit-Qwen"
[providers.openai]
type = "openai"
api_key = "sk-test"
model = "gpt-4o"
[providers.claude]
type = "claude"
api_key = "sk-a"
model = "claude-opus-4-6"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let provider = config.active_provider(None).unwrap();
assert_eq!(provider.model, "claude-opus-4-6");
}
#[test]
fn active_provider_falls_back_when_override_points_to_deleted_provider() {
let toml_str = r#"
default_provider = "openai"
[providers.openai]
type = "openai"
api_key = "sk-test"
model = "gpt-4o"
[providers.claude]
type = "claude"
api_key = "sk-a"
model = "claude-opus-4-6"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let provider = config.active_provider(Some("nonexistent")).unwrap();
assert_eq!(provider.model, "claude-opus-4-6");
}
#[test]
fn active_provider_errors_when_default_deleted_and_no_other_providers() {
let toml_str = r#"
default_provider = "deleted"
[providers]
"#;
let config: Config = toml::from_str(toml_str).unwrap();
let err = config.active_provider(None).unwrap_err();
assert!(
err.to_string().contains("No providers configured"),
"unexpected error: {err}"
);
}
#[test]
fn vision_preprocessor_provider_defaults_to_none() {
let toml_str = r#"
default_provider = "claude"
[providers.claude]
type = "claude"
model = "claude-sonnet-4-5"
api_key = "sk-test"
"#;
let cfg: Config = toml::from_str(toml_str).expect("parse minimal config");
assert_eq!(cfg.vision_preprocessor_provider, None);
}
#[test]
fn saved_config_roundtrips_language() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut cfg = Config {
default_provider: "p".to_string(),
default_workdir: None,
providers: HashMap::new(),
datalog: DatalogConfig::default(),
notifications: NotificationConfig::default(),
auto_update: true,
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: None,
language: Some(crate::locale::Locale::ZhCn),
ui: Default::default(),
plugin: Default::default(),
};
cfg.providers.insert(
"p".to_string(),
ProviderConfig {
provider_type: "openai".to_string(),
api_key: Some("k".to_string()),
model: "m".to_string(),
base_url: None,
system_prompt: None,
user_agent: None,
context_window: 16000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
},
);
cfg.save(tmp.path()).unwrap();
let loaded = Config::load(tmp.path()).unwrap();
assert_eq!(loaded.language, Some(crate::locale::Locale::ZhCn));
}
#[test]
fn config_default_has_no_language() {
let toml_str = r#"
default_provider = "test"
[providers]
"#;
let cfg: Config = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.language, None);
}
#[test]
fn config_missing_language_field_loads_as_none() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "default_provider = \"foo\"\n[providers]\n").unwrap();
let loaded = Config::load(tmp.path()).unwrap();
assert_eq!(loaded.language, None);
}
#[test]
fn vision_preprocessor_provider_round_trips_through_toml() {
let toml_str = r#"
default_provider = "claude"
vision_preprocessor_provider = "AtomGit-Qwen-Qwen3-VL-32B-Instruct"
[providers.claude]
type = "claude"
model = "claude-sonnet-4-5"
api_key = "sk-test"
"#;
let cfg: Config = toml::from_str(toml_str).expect("parse");
assert_eq!(
cfg.vision_preprocessor_provider.as_deref(),
Some("AtomGit-Qwen-Qwen3-VL-32B-Instruct"),
);
}
fn cfg_with(active_model: &str, preprocessor_key: Option<&str>) -> Config {
let mut providers = std::collections::HashMap::new();
providers.insert(
"active".to_string(),
crate::config::provider::ProviderConfig {
provider_type: "openai".into(),
api_key: Some("sk-test".into()),
model: active_model.into(),
base_url: Some("http://127.0.0.1/".into()),
system_prompt: None,
user_agent: None,
context_window: 8000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
},
);
Config {
default_provider: "active".into(),
default_workdir: None,
providers,
datalog: Default::default(),
auto_update: true,
notifications: Default::default(),
telemetry: Default::default(),
lsp: Default::default(),
auto_commit: false,
subagent: Default::default(),
vision_preprocessor_provider: preprocessor_key.map(|s| s.to_string()),
language: None,
ui: Default::default(),
plugin: Default::default(),
}
}
#[test]
fn can_handle_attached_images_true_when_active_provider_accepts_images() {
let cfg = cfg_with("claude-sonnet-4-5", None);
assert!(cfg.can_handle_attached_images());
}
#[test]
fn can_handle_attached_images_false_for_text_only_main_and_no_preprocessor() {
let cfg = cfg_with("deepseek-v4-flash", None);
assert!(!cfg.can_handle_attached_images());
}
#[test]
fn can_handle_attached_images_false_when_preprocessor_key_does_not_resolve() {
let cfg = cfg_with("deepseek-v4-flash", Some("NoSuchProvider"));
assert!(!cfg.can_handle_attached_images());
}
#[test]
fn can_handle_attached_images_false_when_preprocessor_key_is_empty_string() {
let cfg = cfg_with("deepseek-v4-flash", Some(""));
assert!(!cfg.can_handle_attached_images());
}
#[test]
fn can_handle_attached_images_true_when_preprocessor_resolves() {
let mut cfg = cfg_with("deepseek-v4-flash", Some("vl-helper"));
cfg.providers.insert(
"vl-helper".into(),
crate::config::provider::ProviderConfig {
provider_type: "openai".into(),
api_key: Some("sk-vl".into()),
model: "Qwen/Qwen3-VL-32B-Instruct".into(),
base_url: Some("http://127.0.0.1/".into()),
system_prompt: None,
user_agent: None,
context_window: 8000,
max_tokens: None,
thinking_type: None,
thinking_keep: None,
reasoning_history: None,
thinking_enabled: None,
thinking_budget: None,
skip_tls_verify: false,
ephemeral: false,
},
);
assert!(cfg.can_handle_attached_images());
}
}
#[cfg(test)]
mod reflection_config_tests {
use super::*;
#[test]
fn legacy_reflection_cadence_field_is_silently_ignored() {
let toml_text = r#"
default_provider = "claude"
reflection_cadence = 7
[providers]
"#;
let _cfg: Config = toml::from_str(toml_text).expect("legacy field ignored");
}
#[test]
fn notifications_default_when_missing_from_toml() {
let toml_text = r#"
default_provider = "claude"
[providers]
"#;
let cfg: Config = toml::from_str(toml_text).expect("parses config");
assert!(cfg.notifications.enabled);
assert_eq!(cfg.notifications.min_duration_secs, 8);
assert!(cfg.notifications.terminal);
assert!(cfg.notifications.system);
assert!(cfg.notifications.bell);
assert!(cfg.notifications.background_only);
}
}
#[cfg(test)]
mod telemetry_section_tests {
use super::*;
#[test]
fn missing_telemetry_section_uses_defaults() {
let s = r#"
default_provider = "claude"
[providers]
"#;
let c: Config = toml::from_str(s).unwrap();
assert!(c.telemetry.enabled.is_none());
}
#[test]
fn telemetry_section_roundtrip() {
let s = r#"
default_provider = "claude"
[providers]
[telemetry]
enabled = false
endpoint = "https://test.example/v1"
"#;
let c: Config = toml::from_str(s).unwrap();
assert_eq!(c.telemetry.enabled, Some(false));
assert_eq!(
c.telemetry.endpoint.as_deref(),
Some("https://test.example/v1")
);
}
#[test]
fn saved_config_preserves_explicit_telemetry_section() {
let tmp = std::env::temp_dir().join(format!(
"atomcode_cfg_telemetry_rt_{}.toml",
std::process::id()
));
let cfg = Config {
default_provider: "p".to_string(),
telemetry: TelemetryConfig {
enabled: Some(false),
endpoint: Some("https://telemetry.example/v1".to_string()),
},
..Config::default()
};
cfg.save(&tmp).unwrap();
let text = std::fs::read_to_string(&tmp).unwrap();
assert!(text.contains("[telemetry]"));
assert!(text.contains("enabled = false"));
assert!(text.contains("endpoint = \"https://telemetry.example/v1\""));
let reloaded = Config::load(&tmp).unwrap();
assert_eq!(reloaded.telemetry.enabled, Some(false));
assert_eq!(
reloaded.telemetry.endpoint.as_deref(),
Some("https://telemetry.example/v1")
);
let _ = std::fs::remove_file(&tmp);
}
}