Skip to main content

atman_runtime/
config_hub.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4use std::sync::Mutex;
5
6use serde::{Deserialize, Serialize};
7
8use crate::model_registry::ModelConfigUpdate;
9
10static CONFIG_WRITE_LOCK: Mutex<()> = Mutex::new(());
11static AUTH_WRITE_LOCK: Mutex<()> = Mutex::new(());
12static ROUTES_WRITE_LOCK: Mutex<()> = Mutex::new(());
13static LAYOUT_MIGRATION_LOCK: Mutex<()> = Mutex::new(());
14
15#[derive(Debug)]
16pub enum ConfigError {
17    Io(std::io::Error),
18    Parse(toml_edit::TomlError),
19    Invalid(String),
20    NameConflict { name: String, domain: &'static str },
21}
22
23impl fmt::Display for ConfigError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Io(error) => write!(f, "config I/O: {error}"),
27            Self::Parse(error) => write!(f, "parse config.toml: {error}"),
28            Self::Invalid(message) => f.write_str(message),
29            Self::NameConflict { name, domain } => {
30                write!(f, "config name {name:?} already exists in {domain}")
31            }
32        }
33    }
34}
35
36impl std::error::Error for ConfigError {}
37
38impl From<std::io::Error> for ConfigError {
39    fn from(error: std::io::Error) -> Self {
40        Self::Io(error)
41    }
42}
43
44impl From<toml_edit::TomlError> for ConfigError {
45    fn from(error: toml_edit::TomlError) -> Self {
46        Self::Parse(error)
47    }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct DaemonConfig {
52    pub auth_token: String,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ThemePreference {
57    Auto,
58    Light,
59    Dark,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum InterjectionMode {
64    Off,
65    Rule,
66    Llm,
67    Unknown(String),
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct RedactConfig {
72    pub enabled: bool,
73    pub partial: bool,
74    pub allowlist: Vec<String>,
75    pub custom_patterns: Vec<(String, String)>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SandboxConfig {
80    pub enabled: bool,
81    pub strict: bool,
82    pub extra_read: Vec<PathBuf>,
83    pub extra_write: Vec<PathBuf>,
84    pub template_path: Option<PathBuf>,
85    pub allow_network: bool,
86}
87
88impl Default for SandboxConfig {
89    fn default() -> Self {
90        Self {
91            enabled: true,
92            strict: false,
93            extra_read: Vec::new(),
94            extra_write: Vec::new(),
95            template_path: None,
96            allow_network: false,
97        }
98    }
99}
100
101#[derive(Debug, Clone, Copy)]
102pub struct ProviderConfigUpdate<'a> {
103    pub name: &'a str,
104    pub kind: &'a str,
105    pub api_key: Option<&'a str>,
106    pub api_key_env: Option<&'a str>,
107    pub base_url: Option<&'a str>,
108    pub max_tokens: Option<u32>,
109    pub enabled: bool,
110}
111
112pub struct AuthTokenUpdate {
113    pub access_token: String,
114    pub refresh_token: Option<String>,
115    pub expires_at: i64,
116    pub account: Option<String>,
117}
118
119#[derive(Debug, Clone)]
120pub struct ConfigHub {
121    config_dir: PathBuf,
122    daemon_config_path: Option<PathBuf>,
123    auth_path: PathBuf,
124}
125
126impl ConfigHub {
127    pub fn global() -> Result<Self, ConfigError> {
128        let dir = crate::storage::config_dir()
129            .map_err(|error| ConfigError::Invalid(format!("config dir: {error}")))?;
130        Ok(Self::from_config_dir(dir))
131    }
132
133    pub fn from_config_dir(dir: impl Into<PathBuf>) -> Self {
134        let config_dir = dir.into();
135        let auth_path = config_dir.join("auth.json");
136        Self {
137            config_dir,
138            daemon_config_path: None,
139            auth_path,
140        }
141    }
142
143    pub fn config_dir(&self) -> &Path {
144        &self.config_dir
145    }
146
147    pub fn config_toml_path(&self) -> PathBuf {
148        self.config_dir.join("config.toml")
149    }
150
151    pub fn validate_setting_mutation(&self, key: &str, value: &str) -> Result<(), ConfigError> {
152        crate::settings_catalog::validate_mutation(key, value)
153            .map_err(|error| ConfigError::Invalid(error.to_string()))
154    }
155
156    pub fn routes_at_path(&self) -> PathBuf {
157        self.config_dir.join("routes.at")
158    }
159
160    pub fn migrate_legacy_layout(
161        &self,
162        legacy_data_dir: &Path,
163    ) -> Result<Option<crate::config_migration::MigrationReport>, ConfigError> {
164        use fs2::FileExt;
165        let _guard = LAYOUT_MIGRATION_LOCK.lock().unwrap();
166        if legacy_data_dir == self.config_dir || !legacy_data_dir.exists() {
167            return Ok(None);
168        }
169        let lock = std::fs::OpenOptions::new()
170            .read(true)
171            .write(true)
172            .create(true)
173            .truncate(false)
174            .open(legacy_data_dir.join(".config-migration.lock"))?;
175        lock.lock_exclusive()?;
176        let _config_file_lock = lock_file(&self.config_dir.join(".config.toml.lock"))?;
177        let daemon_lock_path = self
178            .daemon_config_path
179            .as_deref()
180            .map(lock_path_for)
181            .unwrap_or_else(|| self.config_dir.join(".daemon.toml.lock"));
182        let _daemon_file_lock = lock_file(&daemon_lock_path)?;
183        let _routes_file_lock = lock_file(&self.config_dir.join(".routes.at.lock"))?;
184        crate::config_migration::relocate_legacy_layout(
185            &self.config_dir,
186            self.daemon_config_path.as_deref(),
187            legacy_data_dir,
188        )
189        .map_err(|error| ConfigError::Invalid(error.to_string()))
190    }
191
192    pub fn storage_config(&self, project_root: Option<&Path>) -> crate::storage::StorageConfig {
193        let global =
194            crate::storage::StorageConfig::load_from(&self.config_toml_path()).unwrap_or_default();
195        let project = project_root
196            .map(|root| {
197                crate::storage::StorageConfig::load_from(&root.join(".atman/config.toml"))
198                    .unwrap_or_default()
199            })
200            .unwrap_or_default();
201        crate::storage::StorageConfig::merge(global, project)
202    }
203
204    pub fn load_routes_source(&self) -> Result<Option<String>, ConfigError> {
205        match std::fs::read_to_string(self.routes_at_path()) {
206            Ok(source) => Ok(Some(source)),
207            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
208            Err(error) => Err(ConfigError::Io(error)),
209        }
210    }
211
212    pub fn append_dsl_route(&self, flow_name: &str, trigger: &str) -> Result<(), ConfigError> {
213        use fs2::FileExt;
214
215        let route = dsl_route_source(flow_name, trigger)?;
216        let _guard = ROUTES_WRITE_LOCK.lock().unwrap();
217        std::fs::create_dir_all(&self.config_dir)?;
218        let lock = std::fs::OpenOptions::new()
219            .read(true)
220            .write(true)
221            .create(true)
222            .truncate(false)
223            .open(self.config_dir.join(".routes.at.lock"))?;
224        lock.lock_exclusive()?;
225
226        let path = self.routes_at_path();
227        let source = match std::fs::read_to_string(&path) {
228            Ok(source) => source,
229            Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
230            Err(error) => return Err(error.into()),
231        };
232        parse_routes_source("existing routes.at", &source)?;
233
234        let mut combined = source;
235        if !combined.is_empty() && !combined.ends_with('\n') {
236            combined.push('\n');
237        }
238        combined.push_str(&route);
239        parse_routes_source("updated routes.at", &combined)?;
240        write_unique_atomic(&path, combined.as_bytes())
241    }
242
243    pub fn mcp_json_path(&self) -> PathBuf {
244        self.config_dir.join("mcp_servers.json")
245    }
246
247    pub fn from_daemon_config_path(path: impl Into<PathBuf>) -> Self {
248        let path = path.into();
249        let config_dir = path
250            .parent()
251            .unwrap_or_else(|| Path::new("."))
252            .to_path_buf();
253        Self::from_config_dir(config_dir).with_daemon_config_path(path)
254    }
255
256    pub fn with_daemon_config_path(mut self, path: impl Into<PathBuf>) -> Self {
257        self.daemon_config_path = Some(path.into());
258        self
259    }
260
261    pub fn from_auth_path(path: impl Into<PathBuf>) -> Self {
262        let auth_path = path.into();
263        let config_dir = auth_path
264            .parent()
265            .unwrap_or_else(|| Path::new("."))
266            .to_path_buf();
267        Self {
268            config_dir,
269            daemon_config_path: None,
270            auth_path,
271        }
272    }
273
274    pub fn load_auth(&self) -> Result<crate::auth_store::AuthStore, ConfigError> {
275        load_auth_from_path(&self.auth_path)
276    }
277
278    pub fn update_auth<T>(
279        &self,
280        mutate: impl FnOnce(&mut crate::auth_store::AuthStore) -> Result<T, ConfigError>,
281    ) -> Result<T, ConfigError> {
282        use fs2::FileExt;
283
284        let _guard = AUTH_WRITE_LOCK.lock().unwrap();
285        let parent = self.auth_path.parent().unwrap_or_else(|| Path::new("."));
286        std::fs::create_dir_all(parent)?;
287        let lock_path = parent.join(".auth.json.lock");
288        let lock = std::fs::OpenOptions::new()
289            .read(true)
290            .write(true)
291            .create(true)
292            .truncate(false)
293            .open(lock_path)?;
294        set_sensitive_file_permissions(
295            &self
296                .auth_path
297                .parent()
298                .unwrap_or_else(|| Path::new("."))
299                .join(".auth.json.lock"),
300        )?;
301        lock.lock_exclusive()?;
302        let mut store = load_auth_from_path(&self.auth_path)?;
303        let result = mutate(&mut store)?;
304        self.write_auth(&store)?;
305        Ok(result)
306    }
307
308    pub fn add_auth_provider(
309        &self,
310        provider: crate::auth_store::StoredProvider,
311    ) -> Result<(), ConfigError> {
312        self.update_auth(|store| {
313            store.providers.push(provider);
314            Ok(())
315        })
316    }
317
318    pub fn remove_auth_provider(&self, id: &str) -> Result<bool, ConfigError> {
319        self.update_auth(|store| Ok(store.remove(id)))
320    }
321
322    pub fn set_auth_provider_enabled(&self, id: &str, enabled: bool) -> Result<bool, ConfigError> {
323        self.update_auth(|store| {
324            let Some(provider) = store
325                .providers
326                .iter_mut()
327                .find(|provider| provider.id == id)
328            else {
329                return Ok(false);
330            };
331            provider.enabled = enabled;
332            Ok(true)
333        })
334    }
335
336    pub fn update_auth_tokens(
337        &self,
338        id: &str,
339        update: AuthTokenUpdate,
340    ) -> Result<bool, ConfigError> {
341        self.update_auth(|store| {
342            let Some(provider) = store
343                .providers
344                .iter_mut()
345                .find(|provider| provider.id == id)
346            else {
347                return Ok(false);
348            };
349            provider.access_token = update.access_token;
350            provider.expires_at = update.expires_at;
351            if update.refresh_token.is_some() {
352                provider.refresh_token = update.refresh_token;
353            }
354            if update.account.is_some() {
355                provider.account = update.account;
356            }
357            Ok(true)
358        })
359    }
360
361    pub fn update_auth_model_cache(
362        &self,
363        id: &str,
364        cache: crate::auth_store::ModelCache,
365    ) -> Result<bool, ConfigError> {
366        self.update_auth(|store| Ok(store.update_model_cache(id, cache)))
367    }
368
369    pub fn load_or_init_daemon_config(&self) -> Result<DaemonConfig, ConfigError> {
370        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
371        let path = self
372            .daemon_config_path
373            .as_deref()
374            .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
375        let _file_lock = lock_file(&lock_path_for(path))?;
376        match std::fs::read_to_string(path) {
377            Ok(text) => toml::from_str(&text).map_err(|error| {
378                ConfigError::Invalid(format!("parse {}: {error}", path.display()))
379            }),
380            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
381                let config = DaemonConfig {
382                    auth_token: generate_daemon_token(),
383                };
384                self.write_daemon_config(&config)?;
385                Ok(config)
386            }
387            Err(error) => Err(error.into()),
388        }
389    }
390
391    pub fn rotate_daemon_config(&self) -> Result<DaemonConfig, ConfigError> {
392        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
393        let path = self
394            .daemon_config_path
395            .as_deref()
396            .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
397        let _file_lock = lock_file(&lock_path_for(path))?;
398        if !path.exists() {
399            return Err(ConfigError::Invalid(format!(
400                "no daemon config at {} — nothing to rotate. Run `atman daemon start` once to generate one.",
401                path.display()
402            )));
403        }
404        let config = DaemonConfig {
405            auth_token: generate_daemon_token(),
406        };
407        self.write_daemon_config(&config)?;
408        Ok(config)
409    }
410
411    pub fn read_config_toml(&self) -> Result<String, ConfigError> {
412        match std::fs::read_to_string(self.config_toml_path()) {
413            Ok(text) => Ok(text),
414            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
415            Err(error) => Err(error.into()),
416        }
417    }
418
419    pub fn theme_preference(&self) -> Result<ThemePreference, ConfigError> {
420        let text = self.read_config_toml()?;
421        if text.trim().is_empty() {
422            return Ok(ThemePreference::Auto);
423        }
424        let document = text.parse::<toml_edit::DocumentMut>()?;
425        let Some(theme) = document.get("theme") else {
426            return Ok(ThemePreference::Auto);
427        };
428        let Some(theme) = theme.as_table() else {
429            return Err(ConfigError::Invalid("theme is not a table".into()));
430        };
431        let Some(mode) = theme.get("mode") else {
432            return Ok(ThemePreference::Auto);
433        };
434        let Some(mode) = mode.as_str() else {
435            return Err(ConfigError::Invalid("theme.mode is not a string".into()));
436        };
437        match mode.to_ascii_lowercase().as_str() {
438            "auto" => Ok(ThemePreference::Auto),
439            "light" => Ok(ThemePreference::Light),
440            "dark" => Ok(ThemePreference::Dark),
441            _ => Err(ConfigError::Invalid(format!(
442                "invalid theme.mode: {mode:?}"
443            ))),
444        }
445    }
446
447    pub fn fs_access_mode(&self) -> Result<Option<crate::fs_access::FsAccessMode>, ConfigError> {
448        let text = self.read_config_toml()?;
449        if text.trim().is_empty() {
450            return Ok(None);
451        }
452        let document = text.parse::<toml_edit::DocumentMut>()?;
453        let Some(fs_access) = document.get("fs_access") else {
454            return Ok(None);
455        };
456        let Some(fs_access) = fs_access.as_table() else {
457            return Err(ConfigError::Invalid("fs_access is not a table".into()));
458        };
459        let Some(mode) = fs_access.get("mode") else {
460            return Ok(None);
461        };
462        let Some(mode) = mode.as_str() else {
463            return Err(ConfigError::Invalid(
464                "fs_access.mode is not a string".into(),
465            ));
466        };
467        crate::fs_access::FsAccessMode::from_str(mode)
468            .map(Some)
469            .map_err(ConfigError::Invalid)
470    }
471
472    pub fn auto_snapshot(&self) -> Result<Option<bool>, ConfigError> {
473        let text = self.read_config_toml()?;
474        if text.trim().is_empty() {
475            return Ok(None);
476        }
477        let document = text.parse::<toml_edit::DocumentMut>()?;
478        let Some(registry) = document.get("registry") else {
479            return Ok(None);
480        };
481        let Some(registry) = registry.as_table() else {
482            return Err(ConfigError::Invalid("registry is not a table".into()));
483        };
484        let Some(auto_snapshot) = registry.get("auto_snapshot") else {
485            return Ok(None);
486        };
487        if let Some(value) = auto_snapshot.as_bool() {
488            return Ok(Some(value));
489        }
490        if let Some(value) = auto_snapshot.as_integer() {
491            return Ok(Some(value == 1));
492        }
493        if let Some(value) = auto_snapshot.as_str() {
494            return Ok(Some(value == "true"));
495        }
496        Err(ConfigError::Invalid(
497            "registry.auto_snapshot has an unsupported type".into(),
498        ))
499    }
500
501    pub fn compact_review_mode(&self) -> Result<Option<crate::CompactReviewMode>, ConfigError> {
502        let text = self.read_config_toml()?;
503        if text.trim().is_empty() {
504            return Ok(None);
505        }
506        let document = text.parse::<toml_edit::DocumentMut>()?;
507        let Some(compaction) = document.get("compaction") else {
508            return Ok(None);
509        };
510        let Some(compaction) = compaction.as_table() else {
511            return Err(ConfigError::Invalid("compaction is not a table".into()));
512        };
513        let Some(review) = compaction.get("review") else {
514            return Ok(None);
515        };
516        let Some(review) = review.as_str() else {
517            return Err(ConfigError::Invalid(
518                "compaction.review is not a string".into(),
519            ));
520        };
521        crate::CompactReviewMode::parse(review)
522            .map(Some)
523            .ok_or_else(|| ConfigError::Invalid(format!("invalid compaction.review: {review:?}")))
524    }
525
526    pub fn suggest_model(&self) -> Result<Option<String>, ConfigError> {
527        let text = self.read_config_toml()?;
528        if text.trim().is_empty() {
529            return Ok(None);
530        }
531        let document = text.parse::<toml_edit::DocumentMut>()?;
532        let Some(suggest) = document.get("suggest") else {
533            return Ok(None);
534        };
535        let Some(suggest) = suggest.as_table() else {
536            return Err(ConfigError::Invalid("suggest is not a table".into()));
537        };
538        let Some(model) = suggest.get("model") else {
539            return Ok(None);
540        };
541        let Some(model) = model.as_str() else {
542            return Err(ConfigError::Invalid("suggest.model is not a string".into()));
543        };
544        Ok(Some(model.to_string()))
545    }
546
547    pub fn interjection_mode(&self) -> Result<Option<InterjectionMode>, ConfigError> {
548        let text = self.read_config_toml()?;
549        if text.trim().is_empty() {
550            return Ok(None);
551        }
552        let document = text.parse::<toml_edit::DocumentMut>()?;
553        let Some(interjection) = document.get("interjection") else {
554            return Ok(None);
555        };
556        let Some(interjection) = interjection.as_table() else {
557            return Err(ConfigError::Invalid("interjection is not a table".into()));
558        };
559        let Some(classifier) = interjection.get("classifier") else {
560            return Ok(None);
561        };
562        let Some(classifier) = classifier.as_str() else {
563            return Err(ConfigError::Invalid(
564                "interjection.classifier is not a string".into(),
565            ));
566        };
567        Ok(Some(match classifier {
568            "off" => InterjectionMode::Off,
569            "rule" => InterjectionMode::Rule,
570            "llm" => InterjectionMode::Llm,
571            other => InterjectionMode::Unknown(other.to_string()),
572        }))
573    }
574
575    pub fn tool_output_budget(
576        &self,
577    ) -> Result<crate::tools::tool_output::ToolOutputBudget, ConfigError> {
578        #[derive(Debug, serde::Deserialize, Default)]
579        struct RawToolOutput {
580            #[serde(default)]
581            max_lines: Option<usize>,
582            #[serde(default)]
583            max_bytes: Option<usize>,
584            #[serde(default)]
585            max_line_bytes: Option<usize>,
586        }
587        #[derive(Debug, serde::Deserialize, Default)]
588        struct RawFile {
589            #[serde(default)]
590            tool_output: RawToolOutput,
591        }
592        let text = self.read_config_toml()?;
593        if text.trim().is_empty() {
594            return Ok(Default::default());
595        }
596        let raw: RawFile = toml::from_str(&text)
597            .map_err(|error| ConfigError::Invalid(format!("tool_output config: {error}")))?;
598        let defaults = crate::tools::tool_output::ToolOutputBudget::default();
599        let budget = crate::tools::tool_output::ToolOutputBudget {
600            max_lines: raw.tool_output.max_lines.unwrap_or(defaults.max_lines),
601            max_bytes: raw.tool_output.max_bytes.unwrap_or(defaults.max_bytes),
602            max_line_bytes: raw
603                .tool_output
604                .max_line_bytes
605                .unwrap_or(defaults.max_line_bytes),
606        };
607        if budget.max_lines == 0 || budget.max_bytes == 0 || budget.max_line_bytes == 0 {
608            return Err(ConfigError::Invalid(
609                "tool_output budgets must be positive".into(),
610            ));
611        }
612        Ok(budget)
613    }
614
615    pub fn web_fetch_config(&self) -> Result<crate::tools::web::WebConfig, ConfigError> {
616        #[derive(Debug, serde::Deserialize, Default)]
617        struct RawWeb {
618            #[serde(default)]
619            max_bytes: Option<usize>,
620            #[serde(default)]
621            url_allowlist: Vec<String>,
622            #[serde(default)]
623            url_denylist: Vec<String>,
624        }
625        #[derive(Debug, serde::Deserialize, Default)]
626        struct RawWebFile {
627            #[serde(default)]
628            web: RawWeb,
629        }
630
631        let text = self.read_config_toml()?;
632        let mut config = crate::tools::web::WebConfig::default();
633        if text.trim().is_empty() {
634            return Ok(config);
635        }
636        let file: RawWebFile = toml::from_str(&text)
637            .map_err(|error| ConfigError::Invalid(format!("parse web fetch config: {error}")))?;
638        if let Some(value) = file.web.max_bytes {
639            config.max_bytes = value;
640        }
641        if !file.web.url_allowlist.is_empty() {
642            config.url_allowlist = file.web.url_allowlist;
643        }
644        if !file.web.url_denylist.is_empty() {
645            config.url_denylist = file.web.url_denylist;
646        }
647        Ok(config)
648    }
649
650    pub fn web_search_config(&self) -> Result<crate::tools::web::SearchConfig, ConfigError> {
651        #[derive(Debug, serde::Deserialize, Default)]
652        struct RawWeb {
653            #[serde(default)]
654            search: Option<crate::tools::web::SearchConfig>,
655        }
656        #[derive(Debug, serde::Deserialize, Default)]
657        struct RawWebFile {
658            #[serde(default)]
659            web: RawWeb,
660        }
661
662        let text = self.read_config_toml()?;
663        if text.trim().is_empty() {
664            return Ok(crate::tools::web::SearchConfig::default());
665        }
666        let file: RawWebFile = toml::from_str(&text)
667            .map_err(|error| ConfigError::Invalid(format!("parse web search config: {error}")))?;
668        Ok(file.web.search.unwrap_or_default())
669    }
670
671    pub fn trust_config(&self) -> Result<crate::trust::TrustConfig, ConfigError> {
672        #[derive(Debug, serde::Deserialize, Default)]
673        struct RawTrustFile {
674            #[serde(default)]
675            trust: crate::trust::TrustConfig,
676        }
677
678        let text = self.read_config_toml()?;
679        if text.trim().is_empty() {
680            return Ok(crate::trust::TrustConfig::default());
681        }
682        let file: RawTrustFile = toml::from_str(&text)
683            .map_err(|error| ConfigError::Invalid(format!("parse trust config: {error}")))?;
684        Ok(file.trust)
685    }
686
687    pub fn preview_config(&self) -> Result<crate::tools::preview::PreviewConfig, ConfigError> {
688        #[derive(Debug, serde::Deserialize, Default)]
689        struct RawPreview {
690            #[serde(default)]
691            base_url: Option<String>,
692            #[serde(default)]
693            timeout_ms: Option<u64>,
694            #[serde(default)]
695            project_abs_path: Option<String>,
696            #[serde(default)]
697            project_hint_slug: Option<String>,
698            #[serde(default)]
699            max_body_bytes: Option<usize>,
700        }
701        #[derive(Debug, serde::Deserialize, Default)]
702        struct RawPreviewFile {
703            #[serde(default)]
704            preview: RawPreview,
705        }
706
707        let text = self.read_config_toml()?;
708        let mut config = crate::tools::preview::PreviewConfig::default();
709        if text.trim().is_empty() {
710            return Ok(config);
711        }
712        let file: RawPreviewFile = toml::from_str(&text)
713            .map_err(|error| ConfigError::Invalid(format!("parse preview config: {error}")))?;
714        if let Some(value) = file.preview.base_url {
715            config.base_url = value;
716        }
717        if let Some(value) = file.preview.timeout_ms {
718            config.timeout_ms = value;
719        }
720        if let Some(value) = file.preview.project_abs_path {
721            config.project_abs_path = value;
722        }
723        if let Some(value) = file.preview.project_hint_slug {
724            config.project_hint_slug = Some(value);
725        }
726        if let Some(value) = file.preview.max_body_bytes {
727            config.max_body_bytes = value;
728        }
729        Ok(config)
730    }
731
732    pub fn sandbox_config(&self) -> Result<SandboxConfig, ConfigError> {
733        #[derive(Debug, serde::Deserialize, Default)]
734        struct RawSandbox {
735            #[serde(default)]
736            enabled: Option<bool>,
737            #[serde(default)]
738            strict: bool,
739            #[serde(default)]
740            extra_read: Vec<String>,
741            #[serde(default)]
742            extra_write: Vec<String>,
743            #[serde(default)]
744            template_path: Option<String>,
745            #[serde(default)]
746            allow_network: Option<bool>,
747        }
748        #[derive(Debug, serde::Deserialize, Default)]
749        struct RawSandboxFile {
750            #[serde(default)]
751            sandbox: RawSandbox,
752        }
753
754        let text = self.read_config_toml()?;
755        if text.trim().is_empty() {
756            return Ok(SandboxConfig::default());
757        }
758        let file: RawSandboxFile = toml::from_str(&text)
759            .map_err(|error| ConfigError::Invalid(format!("parse sandbox config: {error}")))?;
760        Ok(SandboxConfig {
761            enabled: file.sandbox.enabled.unwrap_or(true),
762            strict: file.sandbox.strict,
763            extra_read: file
764                .sandbox
765                .extra_read
766                .into_iter()
767                .map(PathBuf::from)
768                .collect(),
769            extra_write: file
770                .sandbox
771                .extra_write
772                .into_iter()
773                .map(PathBuf::from)
774                .collect(),
775            template_path: file.sandbox.template_path.map(PathBuf::from),
776            allow_network: file.sandbox.allow_network.unwrap_or(false),
777        })
778    }
779
780    pub fn redact_config(&self) -> Result<RedactConfig, ConfigError> {
781        #[derive(Debug, serde::Deserialize, Default)]
782        struct RawPattern {
783            kind: String,
784            regex: String,
785        }
786        #[derive(Debug, serde::Deserialize, Default)]
787        struct RawRedact {
788            #[serde(default)]
789            enabled: bool,
790            #[serde(default)]
791            mode: Option<String>,
792            #[serde(default)]
793            allowlist: Vec<String>,
794            #[serde(default)]
795            custom_patterns: Vec<RawPattern>,
796        }
797        #[derive(Debug, serde::Deserialize, Default)]
798        struct RawRedactFile {
799            #[serde(default)]
800            redact: RawRedact,
801        }
802
803        let text = self.read_config_toml()?;
804        if text.trim().is_empty() {
805            return Ok(RedactConfig::default());
806        }
807        let file: RawRedactFile = toml::from_str(&text)
808            .map_err(|error| ConfigError::Invalid(format!("parse redact config: {error}")))?;
809        Ok(RedactConfig {
810            enabled: file.redact.enabled,
811            partial: file.redact.mode.as_deref() == Some("partial"),
812            allowlist: file.redact.allowlist,
813            custom_patterns: file
814                .redact
815                .custom_patterns
816                .into_iter()
817                .map(|pattern| (pattern.kind, pattern.regex))
818                .collect(),
819        })
820    }
821
822    pub fn upsert_model(&self, update: ModelConfigUpdate<'_>) -> Result<(), ConfigError> {
823        self.update_config_toml(|doc| {
824            validate_model_name(doc, update.old_name, update.name)?;
825            crate::model_registry::apply_model_config_update(doc, update)
826                .map_err(|error| ConfigError::Invalid(error.to_string()))
827        })
828    }
829
830    pub fn upsert_provider(&self, update: ProviderConfigUpdate<'_>) -> Result<(), ConfigError> {
831        self.update_config_toml(|doc| {
832            if doc.get("providers").is_none() {
833                doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
834            }
835            let providers = doc
836                .get_mut("providers")
837                .and_then(toml_edit::Item::as_table_mut)
838                .ok_or_else(|| ConfigError::Invalid("providers is not a table".into()))?;
839            let mut entry = toml_edit::Table::new();
840            entry.insert("kind", toml_edit::value(update.kind));
841            insert_nonempty(&mut entry, "api_key", update.api_key);
842            insert_nonempty(&mut entry, "api_key_env", update.api_key_env);
843            insert_nonempty(&mut entry, "base_url", update.base_url);
844            if let Some(value) = update.max_tokens {
845                entry.insert("max_tokens", toml_edit::value(i64::from(value)));
846            }
847            entry.insert("enabled", toml_edit::value(update.enabled));
848            providers.insert(update.name, toml_edit::Item::Table(entry));
849            Ok(())
850        })
851    }
852
853    pub fn add_alias(&self, alias: &str, model: &str) -> Result<(), ConfigError> {
854        self.update_alias(None, alias, model)
855    }
856
857    pub fn bind_default_model(&self, model: &str) -> Result<(), ConfigError> {
858        self.update_config_toml(|doc| {
859            if table_contains(doc, "models", "smart")? {
860                return Err(ConfigError::NameConflict {
861                    name: "smart".into(),
862                    domain: "models",
863                });
864            }
865            ensure_alias_table(doc)?;
866            let aliases = doc
867                .get_mut("alias")
868                .and_then(toml_edit::Item::as_table_mut)
869                .ok_or_else(|| ConfigError::Invalid("alias is not a table".into()))?;
870            set_alias_model(aliases, "smart", model);
871            if !aliases.contains_key("cheap") {
872                set_alias_model(aliases, "cheap", "smart");
873            }
874            Ok(())
875        })
876    }
877
878    pub fn update_alias(
879        &self,
880        old_alias: Option<&str>,
881        new_alias: &str,
882        model: &str,
883    ) -> Result<(), ConfigError> {
884        self.update_config_toml(|doc| {
885            validate_alias_name(doc, old_alias, new_alias)?;
886            if doc.get("alias").is_none() {
887                doc.insert("alias", toml_edit::Item::Table(toml_edit::Table::new()));
888            }
889            let aliases = doc
890                .get_mut("alias")
891                .and_then(toml_edit::Item::as_table_mut)
892                .ok_or_else(|| ConfigError::Invalid("alias is not a table".into()))?;
893            if let Some(old) = old_alias.filter(|old| *old != new_alias) {
894                aliases.remove(old);
895            }
896            let mut entry = toml_edit::Table::new();
897            entry.insert("model", toml_edit::value(model));
898            aliases.insert(new_alias, toml_edit::Item::Table(entry));
899            Ok(())
900        })
901    }
902
903    pub fn remove_alias(&self, alias: &str) -> Result<(), ConfigError> {
904        self.update_config_toml(|doc| {
905            if let Some(aliases) = doc.get_mut("alias").and_then(toml_edit::Item::as_table_mut) {
906                aliases.remove(alias);
907            }
908            Ok(())
909        })
910    }
911
912    pub fn reload(&self) -> Result<(), ConfigError> {
913        let text = self.read_config_toml()?;
914        crate::model_registry::reload_from_text(&text)
915            .map_err(|error| ConfigError::Invalid(error.to_string()))
916    }
917
918    pub fn model_config(
919        &self,
920    ) -> Result<Option<crate::model_registry::ProviderConfig>, ConfigError> {
921        let text = self.read_config_toml()?;
922        if text.trim().is_empty() {
923            return Ok(None);
924        }
925        let document = text.parse::<toml_edit::DocumentMut>()?;
926        if document.get("providers").is_none()
927            && document.get("models").is_none()
928            && document.get("alias").is_none()
929        {
930            return Ok(None);
931        }
932        let has_model_entries = ["providers", "models", "alias"].iter().any(|section| {
933            document
934                .get(section)
935                .and_then(toml_edit::Item::as_table)
936                .is_some_and(|table| !table.is_empty())
937        });
938        if !has_model_entries {
939            return Ok(None);
940        }
941        crate::model_registry::parse_config(&text)
942            .ok_or_else(|| ConfigError::Invalid("invalid model configuration".into()))
943            .map(Some)
944    }
945
946    pub fn load_mcp(&self) -> Vec<crate::mcp::McpServerConfig> {
947        crate::mcp_config::load_from_dir(self.config_dir(), true)
948    }
949
950    pub fn load_local_mcp(&self) -> Vec<crate::mcp::McpServerConfig> {
951        crate::mcp_config::load_from_dir(self.config_dir(), false)
952    }
953
954    pub fn save_mcp(&self, configs: &[crate::mcp::McpServerConfig]) -> Result<(), ConfigError> {
955        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
956        self.write_mcp(configs)
957    }
958
959    pub fn upsert_mcp(&self, config: crate::mcp::McpServerConfig) -> Result<(), ConfigError> {
960        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
961        let mut configs = self.load_local_mcp();
962        configs.retain(|current| current.name != config.name);
963        configs.push(config);
964        self.write_mcp(&configs)
965    }
966
967    pub fn toggle_mcp(&self, name: &str) -> Result<bool, ConfigError> {
968        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
969        let mut configs = self.load_local_mcp();
970        let config = configs
971            .iter_mut()
972            .find(|config| config.name == name)
973            .ok_or_else(|| ConfigError::Invalid(format!("MCP server {name:?} not found")))?;
974        config.disabled = !config.disabled;
975        let disabled = config.disabled;
976        self.write_mcp(&configs)?;
977        Ok(disabled)
978    }
979
980    pub fn remove_mcp(&self, name: &str) -> Result<(), ConfigError> {
981        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
982        let mut configs = self.load_local_mcp();
983        let before = configs.len();
984        configs.retain(|config| config.name != name);
985        if configs.len() == before {
986            return Err(ConfigError::Invalid(format!(
987                "MCP server {name:?} not found"
988            )));
989        }
990        self.write_mcp(&configs)
991    }
992
993    pub fn migrate_and_reload_models(
994        &self,
995    ) -> Result<crate::model_registry::ModelMigrationOutcome, ConfigError> {
996        let outcome = self.migrate_model_config_if_needed()?;
997        self.reload()?;
998        Ok(outcome)
999    }
1000
1001    pub fn migrate_model_config_if_needed(
1002        &self,
1003    ) -> Result<crate::model_registry::ModelMigrationOutcome, ConfigError> {
1004        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1005        let _file_lock = self.lock_config_file()?;
1006        let text = self.read_config_toml()?;
1007        let Some(migrated) = crate::model_registry::migrate_config_if_needed(&text)? else {
1008            return Ok(crate::model_registry::ModelMigrationOutcome::NotNeeded);
1009        };
1010        let backup = self.config_dir.join("config.toml.bak");
1011        write_sensitive_create_new_or_same(&backup, text.as_bytes())?;
1012        self.write_config_toml(&migrated)?;
1013        Ok(crate::model_registry::ModelMigrationOutcome::Migrated { backup })
1014    }
1015
1016    fn lock_config_file(&self) -> Result<std::fs::File, ConfigError> {
1017        lock_file(&self.config_dir.join(".config.toml.lock"))
1018    }
1019
1020    fn update_config_toml(
1021        &self,
1022        mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
1023    ) -> Result<(), ConfigError> {
1024        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1025        let _file_lock = self.lock_config_file()?;
1026        let text = self.read_config_toml()?;
1027        let mut doc = if text.trim().is_empty() {
1028            toml_edit::DocumentMut::new()
1029        } else {
1030            text.parse()?
1031        };
1032        mutate(&mut doc)?;
1033        let new_text = doc.to_string();
1034        self.write_config_toml(&new_text)?;
1035        crate::model_registry::reload_from_text(&new_text)
1036            .map_err(|error| ConfigError::Invalid(error.to_string()))
1037    }
1038
1039    fn write_config_toml(&self, text: &str) -> Result<(), ConfigError> {
1040        write_unique_atomic(&self.config_toml_path(), text.as_bytes())
1041    }
1042
1043    fn write_daemon_config(&self, config: &DaemonConfig) -> Result<(), ConfigError> {
1044        let text = toml::to_string(config)
1045            .map_err(|error| ConfigError::Invalid(format!("serialize daemon config: {error}")))?;
1046        let path = self
1047            .daemon_config_path
1048            .as_deref()
1049            .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
1050        write_sensitive_atomic(path, text.as_bytes())
1051    }
1052
1053    fn write_auth(&self, store: &crate::auth_store::AuthStore) -> Result<(), ConfigError> {
1054        let json = serde_json::to_vec_pretty(store)
1055            .map_err(|error| ConfigError::Invalid(format!("serialize auth store: {error}")))?;
1056        write_sensitive_atomic(&self.auth_path, &json)
1057    }
1058
1059    fn write_mcp(&self, configs: &[crate::mcp::McpServerConfig]) -> Result<(), ConfigError> {
1060        let json = crate::mcp_config::serialize(configs)
1061            .map_err(|error| ConfigError::Invalid(format!("serialize mcp config: {error}")))?;
1062        self.write_atomic("mcp_servers.json", ".mcp_servers.json.tmp", &json)
1063    }
1064
1065    fn write_atomic(
1066        &self,
1067        filename: &str,
1068        temp_filename: &str,
1069        text: &str,
1070    ) -> Result<(), ConfigError> {
1071        std::fs::create_dir_all(&self.config_dir)?;
1072        let tmp = self.config_dir.join(temp_filename);
1073        std::fs::write(&tmp, text)?;
1074        std::fs::rename(tmp, self.config_dir.join(filename))?;
1075        Ok(())
1076    }
1077}
1078
1079fn dsl_route_source(flow_name: &str, trigger: &str) -> Result<String, ConfigError> {
1080    if syn_identifier(flow_name).is_none() {
1081        return Err(ConfigError::Invalid(format!(
1082            "route flow {flow_name:?} is not a valid DSL identifier"
1083        )));
1084    }
1085    if trigger.is_empty() {
1086        return Err(ConfigError::Invalid(
1087            "route trigger must not be empty".to_string(),
1088        ));
1089    }
1090    let trigger = format!("{trigger:?}");
1091    let route = format!("route {trigger} {{ flow: {flow_name} }}\n");
1092    parse_routes_source("generated route", &route)?;
1093    Ok(route)
1094}
1095
1096fn syn_identifier(value: &str) -> Option<()> {
1097    let source = format!("flow {value}() {{}}\n");
1098    atman_dsl::parse::parse_file(&source).ok().map(|_| ())
1099}
1100
1101fn parse_routes_source(context: &str, source: &str) -> Result<(), ConfigError> {
1102    if source.is_empty() {
1103        return Ok(());
1104    }
1105    atman_dsl::parse::parse_file(source)
1106        .map(|_| ())
1107        .map_err(|error| ConfigError::Invalid(format!("parse {context}: {error}")))
1108}
1109
1110fn lock_path_for(path: &Path) -> PathBuf {
1111    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1112    let name = path
1113        .file_name()
1114        .and_then(|name| name.to_str())
1115        .unwrap_or("config");
1116    parent.join(format!(".{name}.lock"))
1117}
1118
1119fn lock_file(path: &Path) -> Result<std::fs::File, ConfigError> {
1120    use fs2::FileExt;
1121    std::fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new(".")))?;
1122    let lock = std::fs::OpenOptions::new()
1123        .read(true)
1124        .write(true)
1125        .create(true)
1126        .truncate(false)
1127        .open(path)?;
1128    lock.lock_exclusive()?;
1129    Ok(lock)
1130}
1131
1132fn write_unique_atomic(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
1133    use std::io::Write;
1134
1135    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1136    std::fs::create_dir_all(parent)?;
1137    let filename = path
1138        .file_name()
1139        .and_then(|name| name.to_str())
1140        .unwrap_or("config");
1141    let tmp = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4().simple()));
1142    let result = (|| -> Result<(), ConfigError> {
1143        let mut file = std::fs::OpenOptions::new()
1144            .write(true)
1145            .create_new(true)
1146            .open(&tmp)?;
1147        file.write_all(contents)?;
1148        file.sync_all()?;
1149        drop(file);
1150        std::fs::rename(&tmp, path)?;
1151        Ok(())
1152    })();
1153    if result.is_err() {
1154        let _ = std::fs::remove_file(&tmp);
1155    }
1156    result
1157}
1158
1159fn load_auth_from_path(path: &Path) -> Result<crate::auth_store::AuthStore, ConfigError> {
1160    match std::fs::read(path) {
1161        Ok(bytes) => serde_json::from_slice(&bytes)
1162            .map_err(|error| ConfigError::Invalid(format!("parse {}: {error}", path.display()))),
1163        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1164            Ok(crate::auth_store::AuthStore::default())
1165        }
1166        Err(error) => Err(error.into()),
1167    }
1168}
1169
1170fn write_sensitive_create_new_or_same(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
1171    match std::fs::read(path) {
1172        Ok(existing) if existing == contents => return Ok(()),
1173        Ok(_) => {
1174            return Err(ConfigError::Invalid(format!(
1175                "backup conflict at {}",
1176                path.display()
1177            )));
1178        }
1179        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1180        Err(error) => return Err(error.into()),
1181    }
1182    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1183    std::fs::create_dir_all(parent)?;
1184    let mut options = std::fs::OpenOptions::new();
1185    options.write(true).create_new(true);
1186    #[cfg(unix)]
1187    {
1188        use std::os::unix::fs::OpenOptionsExt;
1189        options.mode(0o600);
1190    }
1191    use std::io::Write;
1192    let mut file = options.open(path)?;
1193    set_sensitive_file_permissions(path)?;
1194    if let Err(error) = file.write_all(contents).and_then(|_| file.sync_all()) {
1195        drop(file);
1196        let _ = std::fs::remove_file(path);
1197        return Err(error.into());
1198    }
1199    Ok(())
1200}
1201
1202fn write_sensitive_atomic(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
1203    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1204    std::fs::create_dir_all(parent)?;
1205    let filename = path
1206        .file_name()
1207        .and_then(|name| name.to_str())
1208        .unwrap_or("sensitive-config");
1209    let tmp = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4().simple()));
1210    let mut options = std::fs::OpenOptions::new();
1211    options.write(true).create_new(true);
1212    #[cfg(unix)]
1213    {
1214        use std::os::unix::fs::OpenOptionsExt;
1215        options.mode(0o600);
1216    }
1217    let mut file = options.open(&tmp)?;
1218    set_sensitive_file_permissions(&tmp)?;
1219    use std::io::Write;
1220    file.write_all(contents)?;
1221    drop(file);
1222    std::fs::rename(&tmp, path)?;
1223    Ok(())
1224}
1225
1226fn generate_daemon_token() -> String {
1227    let first = uuid::Uuid::new_v4().simple().to_string();
1228    let second = uuid::Uuid::new_v4().simple().to_string();
1229    format!("{first}{second}")
1230}
1231
1232fn set_sensitive_file_permissions(path: &Path) -> Result<(), ConfigError> {
1233    #[cfg(unix)]
1234    {
1235        use std::os::unix::fs::PermissionsExt;
1236        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
1237    }
1238    Ok(())
1239}
1240
1241fn insert_nonempty(table: &mut toml_edit::Table, key: &str, value: Option<&str>) {
1242    if let Some(value) = value.filter(|value| !value.is_empty()) {
1243        table.insert(key, toml_edit::value(value));
1244    }
1245}
1246
1247fn ensure_alias_table(doc: &mut toml_edit::DocumentMut) -> Result<(), ConfigError> {
1248    if doc.get("alias").is_none() {
1249        doc.insert("alias", toml_edit::Item::Table(toml_edit::Table::new()));
1250    }
1251    if doc
1252        .get("alias")
1253        .and_then(toml_edit::Item::as_table)
1254        .is_none()
1255    {
1256        return Err(ConfigError::Invalid("alias is not a table".into()));
1257    }
1258    Ok(())
1259}
1260
1261fn set_alias_model(table: &mut toml_edit::Table, alias: &str, model: &str) {
1262    let mut entry = toml_edit::Table::new();
1263    entry.insert("model", toml_edit::value(model));
1264    table.insert(alias, toml_edit::Item::Table(entry));
1265}
1266
1267fn validate_model_name(
1268    doc: &toml_edit::DocumentMut,
1269    old_name: Option<&str>,
1270    name: &str,
1271) -> Result<(), ConfigError> {
1272    if old_name != Some(name) && table_contains(doc, "models", name)? {
1273        return Err(ConfigError::NameConflict {
1274            name: name.into(),
1275            domain: "models",
1276        });
1277    }
1278    if table_contains(doc, "alias", name)? {
1279        return Err(ConfigError::NameConflict {
1280            name: name.into(),
1281            domain: "alias",
1282        });
1283    }
1284    Ok(())
1285}
1286
1287fn validate_alias_name(
1288    doc: &toml_edit::DocumentMut,
1289    old_name: Option<&str>,
1290    name: &str,
1291) -> Result<(), ConfigError> {
1292    if table_contains(doc, "models", name)? {
1293        return Err(ConfigError::NameConflict {
1294            name: name.into(),
1295            domain: "models",
1296        });
1297    }
1298    if old_name != Some(name) && table_contains(doc, "alias", name)? {
1299        return Err(ConfigError::NameConflict {
1300            name: name.into(),
1301            domain: "alias",
1302        });
1303    }
1304    Ok(())
1305}
1306
1307fn table_contains(
1308    doc: &toml_edit::DocumentMut,
1309    table: &'static str,
1310    name: &str,
1311) -> Result<bool, ConfigError> {
1312    match doc.get(table) {
1313        None => Ok(false),
1314        Some(item) => item
1315            .as_table()
1316            .map(|items| items.contains_key(name))
1317            .ok_or_else(|| ConfigError::Invalid(format!("{table} is not a table"))),
1318    }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324
1325    fn temp_hub() -> (tempfile::TempDir, ConfigHub) {
1326        let dir = tempfile::tempdir().unwrap();
1327        let hub = ConfigHub::from_config_dir(dir.path());
1328        (dir, hub)
1329    }
1330
1331    #[test]
1332    fn settings_mutation_validation_is_centralized() {
1333        let (_dir, hub) = temp_hub();
1334        assert!(hub.validate_setting_mutation("trust.mode", "allow").is_ok());
1335        assert!(hub.validate_setting_mutation("trust.mode", " ").is_err());
1336        assert!(hub.validate_setting_mutation("missing", "x").is_err());
1337    }
1338
1339    #[test]
1340    fn tool_output_budget_uses_defaults_and_reads_overrides() {
1341        let (dir, hub) = temp_hub();
1342        assert_eq!(
1343            hub.tool_output_budget().unwrap(),
1344            crate::tools::tool_output::ToolOutputBudget {
1345                max_lines: 256,
1346                max_bytes: 10 * 1024,
1347                max_line_bytes: 10 * 1024,
1348            }
1349        );
1350        write_config(
1351            &hub,
1352            "[tool_output]\nmax_lines = 7\nmax_bytes = 777\nmax_line_bytes = 111\n",
1353        );
1354        assert_eq!(
1355            hub.tool_output_budget().unwrap(),
1356            crate::tools::tool_output::ToolOutputBudget {
1357                max_lines: 7,
1358                max_bytes: 777,
1359                max_line_bytes: 111,
1360            }
1361        );
1362        let _ = dir;
1363    }
1364
1365    #[test]
1366    fn tool_output_budget_rejects_zero_values() {
1367        let (_dir, hub) = temp_hub();
1368        write_config(&hub, "[tool_output]\nmax_bytes = 0\n");
1369        assert!(hub.tool_output_budget().is_err());
1370    }
1371
1372    #[test]
1373    fn storage_config_merges_only_typed_storage_projection() {
1374        let (dir, hub) = temp_hub();
1375        std::fs::write(
1376            dir.path().join("config.toml"),
1377            "[storage]\nscope = \"local\"\n[theme]\nmode = \"dark\"\n",
1378        )
1379        .unwrap();
1380        let project = tempfile::tempdir().unwrap();
1381        std::fs::create_dir(project.path().join(".atman")).unwrap();
1382        std::fs::write(
1383            project.path().join(".atman/config.toml"),
1384            "[storage]\nscope = \"global\"\n[theme]\nmode = \"light\"\n",
1385        )
1386        .unwrap();
1387
1388        assert_eq!(
1389            hub.storage_config(Some(project.path())).scope,
1390            Some(crate::storage::StorageScope::Global)
1391        );
1392    }
1393
1394    #[test]
1395    fn storage_config_isolates_invalid_global_and_project_layers() {
1396        let (dir, hub) = temp_hub();
1397        let project = tempfile::tempdir().unwrap();
1398        std::fs::create_dir(project.path().join(".atman")).unwrap();
1399        std::fs::write(dir.path().join("config.toml"), "not valid [").unwrap();
1400        std::fs::write(
1401            project.path().join(".atman/config.toml"),
1402            "[storage]\nscope = \"local\"\n",
1403        )
1404        .unwrap();
1405        assert_eq!(
1406            hub.storage_config(Some(project.path())).scope,
1407            Some(crate::storage::StorageScope::Local)
1408        );
1409
1410        std::fs::write(
1411            dir.path().join("config.toml"),
1412            "[storage]\nscope = \"global\"\n",
1413        )
1414        .unwrap();
1415        std::fs::write(project.path().join(".atman/config.toml"), "not valid [").unwrap();
1416        assert_eq!(
1417            hub.storage_config(Some(project.path())).scope,
1418            Some(crate::storage::StorageScope::Global)
1419        );
1420    }
1421
1422    #[test]
1423    fn storage_config_treats_read_errors_as_empty_layers() {
1424        let (dir, hub) = temp_hub();
1425        std::fs::create_dir(dir.path().join("config.toml")).unwrap();
1426        let project = tempfile::tempdir().unwrap();
1427        std::fs::create_dir(project.path().join(".atman")).unwrap();
1428        std::fs::write(
1429            project.path().join(".atman/config.toml"),
1430            "[storage]\nscope = \"local\"\n",
1431        )
1432        .unwrap();
1433
1434        assert_eq!(
1435            hub.storage_config(Some(project.path())).scope,
1436            Some(crate::storage::StorageScope::Local)
1437        );
1438    }
1439
1440    fn write_config(hub: &ConfigHub, text: &str) {
1441        std::fs::write(hub.config_toml_path(), text).unwrap();
1442    }
1443
1444    #[test]
1445    fn append_dsl_route_creates_missing_file_and_escapes_trigger() {
1446        let (_dir, hub) = temp_hub();
1447        hub.append_dsl_route("review_code", "say \"hi\"\\now\n")
1448            .unwrap();
1449
1450        let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1451        let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1452        assert_eq!(parsed.routes.len(), 1);
1453        assert_eq!(parsed.routes[0].pattern, "say \"hi\"\\now\n");
1454        assert_eq!(parsed.routes[0].flow.name, "review_code");
1455    }
1456
1457    #[test]
1458    fn append_dsl_route_preserves_existing_source_exactly() {
1459        let (_dir, hub) = temp_hub();
1460        let original = "// keep this comment\nroute \"old \" { flow: old_flow }";
1461        std::fs::write(hub.routes_at_path(), original).unwrap();
1462
1463        hub.append_dsl_route("new_flow", "new ").unwrap();
1464
1465        assert_eq!(
1466            std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1467            format!("{original}\nroute \"new \" {{ flow: new_flow }}\n")
1468        );
1469    }
1470
1471    #[test]
1472    fn append_dsl_route_does_not_overwrite_invalid_existing_source() {
1473        let (_dir, hub) = temp_hub();
1474        let invalid = "route invalid";
1475        std::fs::write(hub.routes_at_path(), invalid).unwrap();
1476
1477        let error = hub.append_dsl_route("new_flow", "new ").unwrap_err();
1478
1479        assert!(error.to_string().contains("parse existing routes.at"));
1480        assert_eq!(
1481            std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1482            invalid
1483        );
1484    }
1485
1486    #[test]
1487    fn append_dsl_route_rejects_invalid_flow_without_writing() {
1488        let (_dir, hub) = temp_hub();
1489        let error = hub.append_dsl_route("bad-name", "new ").unwrap_err();
1490        assert!(error.to_string().contains("valid DSL identifier"));
1491        assert!(!hub.routes_at_path().exists());
1492    }
1493
1494    #[test]
1495    fn concurrent_dsl_route_appends_do_not_lose_updates() {
1496        let (_dir, hub) = temp_hub();
1497        let mut workers = Vec::new();
1498        for index in 0..12 {
1499            let hub = hub.clone();
1500            workers.push(std::thread::spawn(move || {
1501                hub.append_dsl_route(&format!("flow_{index}"), &format!("{index} "))
1502                    .unwrap();
1503            }));
1504        }
1505        for worker in workers {
1506            worker.join().unwrap();
1507        }
1508
1509        let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1510        let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1511        assert_eq!(parsed.routes.len(), 12);
1512        for index in 0..12 {
1513            assert!(parsed.routes.iter().any(|route| {
1514                route.flow.name == format!("flow_{index}") && route.pattern == format!("{index} ")
1515            }));
1516        }
1517        assert!(!std::fs::read_dir(hub.config_dir()).unwrap().any(|entry| {
1518            let name = entry.unwrap().file_name();
1519            let name = name.to_string_lossy();
1520            name.starts_with(".routes.at.") && name.ends_with(".tmp")
1521        }));
1522    }
1523
1524    #[test]
1525    fn append_dsl_route_waits_for_external_file_lock() {
1526        use fs2::FileExt;
1527        use std::sync::mpsc::TryRecvError;
1528
1529        let (_dir, hub) = temp_hub();
1530        let lock = std::fs::OpenOptions::new()
1531            .read(true)
1532            .write(true)
1533            .create(true)
1534            .truncate(false)
1535            .open(hub.config_dir().join(".routes.at.lock"))
1536            .unwrap();
1537        lock.lock_exclusive().unwrap();
1538
1539        let worker_hub = hub.clone();
1540        let (tx, rx) = std::sync::mpsc::channel();
1541        let worker = std::thread::spawn(move || {
1542            worker_hub.append_dsl_route("blocked", "wait ").unwrap();
1543            tx.send(()).unwrap();
1544        });
1545        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1546        FileExt::unlock(&lock).unwrap();
1547        rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1548        worker.join().unwrap();
1549    }
1550
1551    #[test]
1552    fn model_config_projection_handles_missing_valid_and_invalid_files() {
1553        let (_dir, hub) = temp_hub();
1554        assert!(hub.model_config().unwrap().is_none());
1555
1556        write_config(
1557            &hub,
1558            "[providers.openai]\nkind = \"openai\"\n[models.fast]\nmodel = \"gpt-4o-mini\"\n[alias.default]\nmodel = \"fast\"\n",
1559        );
1560        let config = hub.model_config().unwrap().unwrap();
1561        assert_eq!(config.providers["openai"].kind, "openai");
1562        assert_eq!(config.models["fast"].model, "gpt-4o-mini");
1563        assert_eq!(config.aliases["default"].model, "fast");
1564
1565        write_config(&hub, "[models]\n");
1566        assert!(hub.model_config().unwrap().is_none());
1567
1568        write_config(&hub, "[models\n");
1569        assert!(hub.model_config().is_err());
1570    }
1571
1572    #[test]
1573    fn theme_preference_defaults_to_auto_when_config_is_missing() {
1574        let (_dir, hub) = temp_hub();
1575
1576        assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1577    }
1578
1579    #[test]
1580    fn theme_preference_defaults_to_auto_when_mode_is_missing() {
1581        let (_dir, hub) = temp_hub();
1582        write_config(&hub, "[theme]\n");
1583
1584        assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1585    }
1586
1587    #[test]
1588    fn theme_preference_parses_supported_modes() {
1589        for (mode, expected) in [
1590            ("auto", ThemePreference::Auto),
1591            ("light", ThemePreference::Light),
1592            ("LiGhT", ThemePreference::Light),
1593            ("dark", ThemePreference::Dark),
1594        ] {
1595            let (_dir, hub) = temp_hub();
1596            write_config(&hub, &format!("[theme]\nmode = {mode:?}\n"));
1597
1598            assert_eq!(hub.theme_preference().unwrap(), expected);
1599        }
1600    }
1601
1602    #[test]
1603    fn theme_preference_rejects_unknown_mode() {
1604        let (_dir, hub) = temp_hub();
1605        write_config(&hub, "[theme]\nmode = \"sepia\"\n");
1606
1607        assert!(matches!(
1608            hub.theme_preference(),
1609            Err(ConfigError::Invalid(message)) if message.contains("theme.mode")
1610        ));
1611    }
1612
1613    fn auth_provider(id: &str) -> crate::auth_store::StoredProvider {
1614        crate::auth_store::StoredProvider {
1615            id: id.into(),
1616            name: id.into(),
1617            kind: crate::auth_store::ProviderKind::Codex,
1618            access_token: "old-access".into(),
1619            refresh_token: Some("old-refresh".into()),
1620            expires_at: 1,
1621            account: Some("old-account".into()),
1622            enabled: true,
1623            model_cache: None,
1624        }
1625    }
1626
1627    #[test]
1628    fn auth_transactions_preserve_independent_concurrent_updates() {
1629        let dir = tempfile::tempdir().unwrap();
1630        let path = dir.path().join("auth.json");
1631        let hub = ConfigHub::from_auth_path(&path);
1632        hub.add_auth_provider(auth_provider("provider")).unwrap();
1633
1634        let cache_hub = hub.clone();
1635        let cache = std::thread::spawn(move || {
1636            cache_hub
1637                .update_auth_model_cache(
1638                    "provider",
1639                    crate::auth_store::ModelCache {
1640                        fetched_at: 10,
1641                        models: vec![crate::auth_store::CachedModel {
1642                            slug: "cached-model".into(),
1643                            context_budget: Some(8192),
1644                            thinking: true,
1645                        }],
1646                    },
1647                )
1648                .unwrap();
1649        });
1650        let token_hub = hub.clone();
1651        let tokens = std::thread::spawn(move || {
1652            token_hub
1653                .update_auth_tokens(
1654                    "provider",
1655                    AuthTokenUpdate {
1656                        access_token: "new-access".into(),
1657                        refresh_token: Some("new-refresh".into()),
1658                        expires_at: 99,
1659                        account: None,
1660                    },
1661                )
1662                .unwrap();
1663        });
1664        let enabled_hub = hub.clone();
1665        let enabled = std::thread::spawn(move || {
1666            enabled_hub
1667                .set_auth_provider_enabled("provider", false)
1668                .unwrap();
1669        });
1670        cache.join().unwrap();
1671        tokens.join().unwrap();
1672        enabled.join().unwrap();
1673
1674        let store = hub.load_auth().unwrap();
1675        let provider = &store.providers[0];
1676        assert_eq!(provider.access_token, "new-access");
1677        assert_eq!(provider.refresh_token.as_deref(), Some("new-refresh"));
1678        assert_eq!(provider.expires_at, 99);
1679        assert_eq!(provider.account.as_deref(), Some("old-account"));
1680        assert!(!provider.enabled);
1681        assert_eq!(
1682            provider.model_cache.as_ref().unwrap().models[0].slug,
1683            "cached-model"
1684        );
1685        #[cfg(unix)]
1686        {
1687            use std::os::unix::fs::PermissionsExt;
1688            assert_eq!(
1689                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1690                0o600
1691            );
1692        }
1693        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1694            let name = entry.unwrap().file_name();
1695            let name = name.to_string_lossy();
1696            name.starts_with(".auth.json.") && name.ends_with(".tmp")
1697        }));
1698    }
1699
1700    #[test]
1701    fn auth_transaction_waits_for_external_file_lock() {
1702        use fs2::FileExt;
1703        use std::sync::mpsc::TryRecvError;
1704
1705        let dir = tempfile::tempdir().unwrap();
1706        let path = dir.path().join("auth.json");
1707        let lock_path = dir.path().join(".auth.json.lock");
1708        let lock = std::fs::OpenOptions::new()
1709            .read(true)
1710            .write(true)
1711            .create(true)
1712            .truncate(false)
1713            .open(lock_path)
1714            .unwrap();
1715        lock.lock_exclusive().unwrap();
1716
1717        let hub = ConfigHub::from_auth_path(&path);
1718        let (tx, rx) = std::sync::mpsc::channel();
1719        let worker = std::thread::spawn(move || {
1720            hub.add_auth_provider(auth_provider("blocked")).unwrap();
1721            tx.send(()).unwrap();
1722        });
1723        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1724        FileExt::unlock(&lock).unwrap();
1725        rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1726        worker.join().unwrap();
1727    }
1728
1729    #[test]
1730    fn auth_transaction_error_rolls_back_without_writing() {
1731        let dir = tempfile::tempdir().unwrap();
1732        let path = dir.path().join("auth.json");
1733        let hub = ConfigHub::from_auth_path(&path);
1734        hub.add_auth_provider(auth_provider("original")).unwrap();
1735        let before = std::fs::read(&path).unwrap();
1736
1737        let result: Result<(), ConfigError> = hub.update_auth(|store| {
1738            store.providers.push(auth_provider("discarded"));
1739            Err(ConfigError::Invalid("reject mutation".into()))
1740        });
1741        assert!(result.is_err());
1742        assert_eq!(std::fs::read(&path).unwrap(), before);
1743    }
1744
1745    #[test]
1746    fn auth_transaction_does_not_overwrite_corrupt_json() {
1747        let dir = tempfile::tempdir().unwrap();
1748        let path = dir.path().join("auth.json");
1749        let corrupt = b"{not-json";
1750        std::fs::write(&path, corrupt).unwrap();
1751        let hub = ConfigHub::from_auth_path(&path);
1752
1753        let err = hub.add_auth_provider(auth_provider("new")).unwrap_err();
1754        assert!(err.to_string().contains("parse"));
1755        assert_eq!(std::fs::read(&path).unwrap(), corrupt);
1756    }
1757
1758    #[test]
1759    fn auth_load_defaults_when_file_is_missing() {
1760        let dir = tempfile::tempdir().unwrap();
1761        let hub = ConfigHub::from_auth_path(dir.path().join("auth.json"));
1762        assert!(hub.load_auth().unwrap().providers.is_empty());
1763    }
1764
1765    #[test]
1766    fn daemon_config_initializes_reuses_and_rotates_atomically() {
1767        let dir = tempfile::tempdir().unwrap();
1768        let path = dir.path().join("daemon.toml");
1769        let hub = ConfigHub::from_daemon_config_path(&path);
1770
1771        let first = hub.load_or_init_daemon_config().unwrap();
1772        assert_eq!(first.auth_token.len(), 64);
1773        assert!(first.auth_token.chars().all(|c| c.is_ascii_hexdigit()));
1774        let second = hub.load_or_init_daemon_config().unwrap();
1775        assert_eq!(second, first);
1776        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1777            let name = entry.unwrap().file_name();
1778            let name = name.to_string_lossy();
1779            name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1780        }));
1781        #[cfg(unix)]
1782        {
1783            use std::os::unix::fs::PermissionsExt;
1784            assert_eq!(
1785                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1786                0o600
1787            );
1788        }
1789
1790        let rotated = hub.rotate_daemon_config().unwrap();
1791        assert_ne!(rotated.auth_token, first.auth_token);
1792        assert_eq!(hub.load_or_init_daemon_config().unwrap(), rotated);
1793        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1794            let name = entry.unwrap().file_name();
1795            let name = name.to_string_lossy();
1796            name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1797        }));
1798    }
1799
1800    #[test]
1801    fn daemon_config_waits_for_external_file_lock() {
1802        use std::sync::mpsc::TryRecvError;
1803
1804        let dir = tempfile::tempdir().unwrap();
1805        let path = dir.path().join("custom-daemon.toml");
1806        let lock = lock_file(&lock_path_for(&path)).unwrap();
1807        let hub = ConfigHub::from_daemon_config_path(&path);
1808        let (tx, rx) = std::sync::mpsc::channel();
1809        let worker = std::thread::spawn(move || {
1810            tx.send(hub.load_or_init_daemon_config()).unwrap();
1811        });
1812
1813        std::thread::sleep(std::time::Duration::from_millis(25));
1814        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1815        lock.unlock().unwrap();
1816        assert!(
1817            rx.recv_timeout(std::time::Duration::from_secs(1))
1818                .unwrap()
1819                .is_ok()
1820        );
1821        worker.join().unwrap();
1822    }
1823
1824    #[cfg(unix)]
1825    #[test]
1826    fn legacy_daemon_config_uses_custom_path_and_owner_only_permissions() {
1827        use std::os::unix::fs::PermissionsExt;
1828
1829        let config = tempfile::tempdir().unwrap();
1830        let data = tempfile::tempdir().unwrap();
1831        let daemon_path = config.path().join("daemon/custom.toml");
1832        std::fs::write(data.path().join("daemon.toml"), "auth_token = \"legacy\"\n").unwrap();
1833
1834        let report = ConfigHub::from_config_dir(config.path())
1835            .with_daemon_config_path(&daemon_path)
1836            .migrate_legacy_layout(data.path())
1837            .unwrap()
1838            .unwrap();
1839
1840        assert!(report.moved.iter().any(|path| path == "daemon.toml"));
1841        assert_eq!(
1842            std::fs::read_to_string(&daemon_path).unwrap(),
1843            "auth_token = \"legacy\"\n"
1844        );
1845        assert!(!config.path().join("daemon.toml").exists());
1846        assert_eq!(
1847            std::fs::metadata(&daemon_path)
1848                .unwrap()
1849                .permissions()
1850                .mode()
1851                & 0o777,
1852            0o600
1853        );
1854    }
1855
1856    #[test]
1857    fn daemon_config_rotation_requires_existing_file() {
1858        let dir = tempfile::tempdir().unwrap();
1859        let path = dir.path().join("missing.toml");
1860        let err = ConfigHub::from_daemon_config_path(&path)
1861            .rotate_daemon_config()
1862            .unwrap_err();
1863        assert!(err.to_string().contains("no daemon config"));
1864    }
1865
1866    #[test]
1867    fn web_configs_default_when_config_or_section_is_missing() {
1868        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1869            let (_dir, hub) = temp_hub();
1870            if let Some(text) = text {
1871                write_config(&hub, text);
1872            }
1873
1874            let fetch = hub.web_fetch_config().unwrap();
1875            assert_eq!(fetch.max_bytes, 1_000_000);
1876            assert!(fetch.url_allowlist.is_empty());
1877            assert!(fetch.url_denylist.is_empty());
1878            let search = hub.web_search_config().unwrap();
1879            assert_eq!(search.provider_name(), "tavily");
1880        }
1881    }
1882
1883    #[test]
1884    fn web_configs_parse_fetch_and_search_fields() {
1885        let (_dir, hub) = temp_hub();
1886        write_config(
1887            &hub,
1888            r#"
1889[web]
1890max_bytes = 4096
1891url_allowlist = ["https://ok.example"]
1892url_denylist = ["https://ok.example/private"]
1893
1894[web.search]
1895provider = "searxng"
1896base_url = "http://localhost:8080"
1897max_results = 6
1898"#,
1899        );
1900
1901        let fetch = hub.web_fetch_config().unwrap();
1902        assert_eq!(fetch.max_bytes, 4096);
1903        assert_eq!(fetch.url_allowlist, vec!["https://ok.example"]);
1904        assert_eq!(fetch.url_denylist, vec!["https://ok.example/private"]);
1905        assert_eq!(hub.web_search_config().unwrap().provider_name(), "searxng");
1906    }
1907
1908    #[test]
1909    fn web_fetch_schema_error_does_not_break_valid_search() {
1910        let (_dir, hub) = temp_hub();
1911        write_config(
1912            &hub,
1913            "[web]\nmax_bytes = \"large\"\n[web.search]\nprovider = \"none\"\n",
1914        );
1915
1916        assert!(matches!(
1917            hub.web_fetch_config(),
1918            Err(ConfigError::Invalid(_))
1919        ));
1920        assert_eq!(hub.web_search_config().unwrap().provider_name(), "none");
1921    }
1922
1923    #[test]
1924    fn web_search_schema_error_does_not_break_valid_fetch() {
1925        let (_dir, hub) = temp_hub();
1926        write_config(
1927            &hub,
1928            "[web]\nmax_bytes = 2048\n[web.search]\nprovider = \"unknown\"\n",
1929        );
1930
1931        assert_eq!(hub.web_fetch_config().unwrap().max_bytes, 2048);
1932        assert!(matches!(
1933            hub.web_search_config(),
1934            Err(ConfigError::Invalid(_))
1935        ));
1936    }
1937
1938    #[test]
1939    fn trust_config_defaults_when_config_or_section_is_missing() {
1940        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1941            let (_dir, hub) = temp_hub();
1942            if let Some(text) = text {
1943                write_config(&hub, text);
1944            }
1945
1946            let config = hub.trust_config().unwrap();
1947            assert_eq!(config.mode, crate::trust::TrustMode::Steady);
1948            assert_eq!(config.theme, crate::trust::Theme::Default);
1949            assert_eq!(config.outside, crate::trust::OutsideBehavior::Approve);
1950        }
1951    }
1952
1953    #[test]
1954    fn trust_config_parses_mode_theme_and_outside() {
1955        let (_dir, hub) = temp_hub();
1956        write_config(
1957            &hub,
1958            "[trust]\nmode = \"eager\"\ntheme = \"weather\"\noutside = \"deny\"\n",
1959        );
1960
1961        let config = hub.trust_config().unwrap();
1962        assert_eq!(config.mode, crate::trust::TrustMode::Eager);
1963        assert_eq!(config.theme, crate::trust::Theme::Weather);
1964        assert_eq!(config.outside, crate::trust::OutsideBehavior::Deny);
1965    }
1966
1967    #[test]
1968    fn trust_config_rejects_invalid_enum() {
1969        let (_dir, hub) = temp_hub();
1970        write_config(&hub, "[trust]\noutside = \"sometimes\"\n");
1971
1972        assert!(matches!(
1973            hub.trust_config(),
1974            Err(ConfigError::Invalid(message)) if message.contains("parse trust config")
1975        ));
1976    }
1977
1978    #[test]
1979    fn preview_config_defaults_when_config_or_section_is_missing() {
1980        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1981            let (_dir, hub) = temp_hub();
1982            if let Some(text) = text {
1983                write_config(&hub, text);
1984            }
1985
1986            let config = hub.preview_config().unwrap();
1987            let expected = crate::tools::preview::PreviewConfig::default();
1988            assert_eq!(config.base_url, expected.base_url);
1989            assert_eq!(config.timeout_ms, expected.timeout_ms);
1990            assert_eq!(config.project_abs_path, expected.project_abs_path);
1991            assert_eq!(config.project_hint_slug, expected.project_hint_slug);
1992            assert_eq!(config.max_body_bytes, expected.max_body_bytes);
1993        }
1994    }
1995
1996    #[test]
1997    fn preview_config_parses_all_supported_fields() {
1998        let (_dir, hub) = temp_hub();
1999        write_config(
2000            &hub,
2001            r#"
2002[preview]
2003base_url = "http://127.0.0.1:9000"
2004timeout_ms = 4500
2005project_abs_path = "/tmp/project"
2006project_hint_slug = "project"
2007max_body_bytes = 2048
2008"#,
2009        );
2010
2011        let config = hub.preview_config().unwrap();
2012        assert_eq!(config.base_url, "http://127.0.0.1:9000");
2013        assert_eq!(config.timeout_ms, 4500);
2014        assert_eq!(config.project_abs_path, "/tmp/project");
2015        assert_eq!(config.project_hint_slug.as_deref(), Some("project"));
2016        assert_eq!(config.max_body_bytes, 2048);
2017    }
2018
2019    #[test]
2020    fn preview_config_rejects_invalid_schema() {
2021        let (_dir, hub) = temp_hub();
2022        write_config(&hub, "[preview]\ntimeout_ms = \"slow\"\n");
2023
2024        assert!(matches!(
2025            hub.preview_config(),
2026            Err(ConfigError::Invalid(message)) if message.contains("parse preview config")
2027        ));
2028    }
2029
2030    #[test]
2031    fn sandbox_config_defaults_when_config_or_section_is_missing() {
2032        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
2033            let (_dir, hub) = temp_hub();
2034            if let Some(text) = text {
2035                write_config(&hub, text);
2036            }
2037
2038            assert_eq!(hub.sandbox_config().unwrap(), SandboxConfig::default());
2039        }
2040    }
2041
2042    #[test]
2043    fn sandbox_config_preserves_paths_and_defaults_missing_enabled() {
2044        let (_dir, hub) = temp_hub();
2045        write_config(
2046            &hub,
2047            r#"
2048[sandbox]
2049strict = true
2050extra_read = ["../read"]
2051extra_write = ["/tmp/write"]
2052template_path = "profiles/custom.sb"
2053allow_network = true
2054"#,
2055        );
2056
2057        assert_eq!(
2058            hub.sandbox_config().unwrap(),
2059            SandboxConfig {
2060                enabled: true,
2061                strict: true,
2062                extra_read: vec![PathBuf::from("../read")],
2063                extra_write: vec![PathBuf::from("/tmp/write")],
2064                template_path: Some(PathBuf::from("profiles/custom.sb")),
2065                allow_network: true,
2066            }
2067        );
2068    }
2069
2070    #[test]
2071    fn sandbox_config_allows_explicit_opt_out() {
2072        let (_dir, hub) = temp_hub();
2073        write_config(&hub, "[sandbox]\nenabled = false\n");
2074
2075        assert!(!hub.sandbox_config().unwrap().enabled);
2076    }
2077
2078    #[test]
2079    fn sandbox_config_rejects_invalid_schema() {
2080        let (_dir, hub) = temp_hub();
2081        write_config(&hub, "[sandbox]\nextra_read = \"/tmp\"\n");
2082
2083        assert!(matches!(
2084            hub.sandbox_config(),
2085            Err(ConfigError::Invalid(message)) if message.contains("parse sandbox config")
2086        ));
2087    }
2088
2089    #[test]
2090    fn redact_config_defaults_when_config_is_missing_or_section_is_missing() {
2091        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
2092            let (_dir, hub) = temp_hub();
2093            if let Some(text) = text {
2094                write_config(&hub, text);
2095            }
2096
2097            assert_eq!(hub.redact_config().unwrap(), RedactConfig::default());
2098        }
2099    }
2100
2101    #[test]
2102    fn redact_config_parses_mode_patterns_and_allowlist() {
2103        let (_dir, hub) = temp_hub();
2104        write_config(
2105            &hub,
2106            r#"
2107[redact]
2108enabled = true
2109mode = "partial"
2110allowlist = ["safe@example.com"]
2111custom_patterns = [{ kind = "ticket", regex = "T-[0-9]+" }]
2112"#,
2113        );
2114
2115        assert_eq!(
2116            hub.redact_config().unwrap(),
2117            RedactConfig {
2118                enabled: true,
2119                partial: true,
2120                allowlist: vec!["safe@example.com".into()],
2121                custom_patterns: vec![("ticket".into(), "T-[0-9]+".into())],
2122            }
2123        );
2124    }
2125
2126    #[test]
2127    fn redact_config_treats_unknown_mode_as_full() {
2128        let (_dir, hub) = temp_hub();
2129        write_config(&hub, "[redact]\nenabled = true\nmode = \"unknown\"\n");
2130
2131        let config = hub.redact_config().unwrap();
2132        assert!(config.enabled);
2133        assert!(!config.partial);
2134    }
2135
2136    #[test]
2137    fn redact_config_rejects_invalid_schema() {
2138        let (_dir, hub) = temp_hub();
2139        write_config(&hub, "[redact]\nenabled = \"yes\"\n");
2140
2141        assert!(matches!(
2142            hub.redact_config(),
2143            Err(ConfigError::Invalid(message)) if message.contains("parse redact config")
2144        ));
2145    }
2146
2147    #[test]
2148    fn interjection_mode_defaults_to_none_when_config_or_value_is_missing() {
2149        for text in [
2150            None,
2151            Some("[theme]\nmode = \"dark\"\n"),
2152            Some("[interjection]\n"),
2153        ] {
2154            let (_dir, hub) = temp_hub();
2155            if let Some(text) = text {
2156                write_config(&hub, text);
2157            }
2158
2159            assert_eq!(hub.interjection_mode().unwrap(), None);
2160        }
2161    }
2162
2163    #[test]
2164    fn interjection_mode_parses_supported_and_unknown_values() {
2165        for (value, expected) in [
2166            ("off", InterjectionMode::Off),
2167            ("rule", InterjectionMode::Rule),
2168            ("llm", InterjectionMode::Llm),
2169            ("custom", InterjectionMode::Unknown("custom".into())),
2170        ] {
2171            let (_dir, hub) = temp_hub();
2172            write_config(&hub, &format!("[interjection]\nclassifier = {value:?}\n"));
2173
2174            assert_eq!(hub.interjection_mode().unwrap(), Some(expected));
2175        }
2176    }
2177
2178    #[test]
2179    fn interjection_mode_rejects_non_string_value() {
2180        let (_dir, hub) = temp_hub();
2181        write_config(&hub, "[interjection]\nclassifier = true\n");
2182
2183        assert!(matches!(
2184            hub.interjection_mode(),
2185            Err(ConfigError::Invalid(message)) if message.contains("interjection.classifier")
2186        ));
2187    }
2188
2189    #[test]
2190    fn suggest_model_defaults_to_none_when_config_or_value_is_missing() {
2191        for text in [
2192            None,
2193            Some("[theme]\nmode = \"dark\"\n"),
2194            Some("[suggest]\n"),
2195        ] {
2196            let (_dir, hub) = temp_hub();
2197            if let Some(text) = text {
2198                write_config(&hub, text);
2199            }
2200
2201            assert_eq!(hub.suggest_model().unwrap(), None);
2202        }
2203    }
2204
2205    #[test]
2206    fn suggest_model_returns_configured_string_including_empty() {
2207        for value in ["smart", ""] {
2208            let (_dir, hub) = temp_hub();
2209            write_config(&hub, &format!("[suggest]\nmodel = {value:?}\n"));
2210
2211            assert_eq!(hub.suggest_model().unwrap().as_deref(), Some(value));
2212        }
2213    }
2214
2215    #[test]
2216    fn suggest_model_rejects_non_string_value() {
2217        let (_dir, hub) = temp_hub();
2218        write_config(&hub, "[suggest]\nmodel = 42\n");
2219
2220        assert!(matches!(
2221            hub.suggest_model(),
2222            Err(ConfigError::Invalid(message)) if message.contains("suggest.model")
2223        ));
2224    }
2225
2226    #[test]
2227    fn compact_review_mode_defaults_to_none_when_config_or_value_is_missing() {
2228        for text in [
2229            None,
2230            Some("[theme]\nmode = \"dark\"\n"),
2231            Some("[compaction]\n"),
2232        ] {
2233            let (_dir, hub) = temp_hub();
2234            if let Some(text) = text {
2235                write_config(&hub, text);
2236            }
2237
2238            assert_eq!(hub.compact_review_mode().unwrap(), None);
2239        }
2240    }
2241
2242    #[test]
2243    fn compact_review_mode_parses_supported_values() {
2244        for (value, expected) in [
2245            ("always", crate::CompactReviewMode::Always),
2246            ("manual-only", crate::CompactReviewMode::ManualOnly),
2247            ("manual_only", crate::CompactReviewMode::ManualOnly),
2248            ("never", crate::CompactReviewMode::Never),
2249        ] {
2250            let (_dir, hub) = temp_hub();
2251            write_config(&hub, &format!("[compaction]\nreview = {value:?}\n"));
2252
2253            assert_eq!(hub.compact_review_mode().unwrap(), Some(expected));
2254        }
2255    }
2256
2257    #[test]
2258    fn compact_review_mode_rejects_unknown_or_non_string_value() {
2259        for value in ["\"sometimes\"", "true"] {
2260            let (_dir, hub) = temp_hub();
2261            write_config(&hub, &format!("[compaction]\nreview = {value}\n"));
2262
2263            assert!(matches!(
2264                hub.compact_review_mode(),
2265                Err(ConfigError::Invalid(message)) if message.contains("compaction.review")
2266            ));
2267        }
2268    }
2269
2270    #[test]
2271    fn auto_snapshot_defaults_to_none_when_config_or_value_is_missing() {
2272        for text in [
2273            None,
2274            Some("[theme]\nmode = \"dark\"\n"),
2275            Some("[registry]\n"),
2276        ] {
2277            let (_dir, hub) = temp_hub();
2278            if let Some(text) = text {
2279                write_config(&hub, text);
2280            }
2281
2282            assert_eq!(hub.auto_snapshot().unwrap(), None);
2283        }
2284    }
2285
2286    #[test]
2287    fn auto_snapshot_reads_boolean_values() {
2288        for value in [true, false] {
2289            let (_dir, hub) = temp_hub();
2290            write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2291
2292            assert_eq!(hub.auto_snapshot().unwrap(), Some(value));
2293        }
2294    }
2295
2296    #[test]
2297    fn auto_snapshot_reads_integer_values() {
2298        for (value, expected) in [(1, true), (0, false)] {
2299            let (_dir, hub) = temp_hub();
2300            write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2301
2302            assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2303        }
2304    }
2305
2306    #[test]
2307    fn auto_snapshot_only_enables_exact_true_string() {
2308        for (value, expected) in [("true", true), ("yes", false)] {
2309            let (_dir, hub) = temp_hub();
2310            write_config(&hub, &format!("[registry]\nauto_snapshot = {value:?}\n"));
2311
2312            assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2313        }
2314    }
2315
2316    #[test]
2317    fn auto_snapshot_rejects_unsupported_type() {
2318        let (_dir, hub) = temp_hub();
2319        write_config(&hub, "[registry]\nauto_snapshot = [true]\n");
2320
2321        assert!(matches!(
2322            hub.auto_snapshot(),
2323            Err(ConfigError::Invalid(message)) if message.contains("registry.auto_snapshot")
2324        ));
2325    }
2326
2327    #[test]
2328    fn fs_access_mode_defaults_to_none_when_config_is_missing() {
2329        let (_dir, hub) = temp_hub();
2330
2331        assert_eq!(hub.fs_access_mode().unwrap(), None);
2332    }
2333
2334    #[test]
2335    fn fs_access_mode_defaults_to_none_when_section_or_mode_is_missing() {
2336        for text in ["[theme]\nmode = \"dark\"\n", "[fs_access]\n"] {
2337            let (_dir, hub) = temp_hub();
2338            write_config(&hub, text);
2339
2340            assert_eq!(hub.fs_access_mode().unwrap(), None);
2341        }
2342    }
2343
2344    #[test]
2345    fn fs_access_mode_parses_canonical_and_alias_values() {
2346        for (mode, expected) in [
2347            ("read-only", crate::fs_access::FsAccessMode::ReadOnly),
2348            ("ws", crate::fs_access::FsAccessMode::WorkspaceWrite),
2349            (
2350                "danger-full-access",
2351                crate::fs_access::FsAccessMode::DangerFullAccess,
2352            ),
2353        ] {
2354            let (_dir, hub) = temp_hub();
2355            write_config(&hub, &format!("[fs_access]\nmode = {mode:?}\n"));
2356
2357            assert_eq!(hub.fs_access_mode().unwrap(), Some(expected));
2358        }
2359    }
2360
2361    #[test]
2362    fn fs_access_mode_rejects_unknown_mode() {
2363        let (_dir, hub) = temp_hub();
2364        write_config(&hub, "[fs_access]\nmode = \"chaos\"\n");
2365
2366        assert!(matches!(
2367            hub.fs_access_mode(),
2368            Err(ConfigError::Invalid(message)) if message.contains("unknown fs access mode")
2369        ));
2370    }
2371
2372    #[test]
2373    fn fs_access_mode_rejects_non_string_mode() {
2374        let (_dir, hub) = temp_hub();
2375        write_config(&hub, "[fs_access]\nmode = true\n");
2376
2377        assert!(matches!(
2378            hub.fs_access_mode(),
2379            Err(ConfigError::Invalid(message)) if message.contains("fs_access.mode")
2380        ));
2381    }
2382
2383    fn model<'a>(
2384        old_name: Option<&'a str>,
2385        name: &'a str,
2386        model: &'a str,
2387    ) -> ModelConfigUpdate<'a> {
2388        ModelConfigUpdate {
2389            old_name,
2390            name,
2391            model,
2392            provider: Some("test"),
2393            context_budget: 100_000,
2394            thinking: false,
2395            max_tokens: None,
2396            enabled: true,
2397        }
2398    }
2399
2400    #[test]
2401    fn model_migration_preserves_existing_provider_name() {
2402        let (_dir, hub) = temp_hub();
2403        write_config(
2404            &hub,
2405            r#"[providers.openai]
2406kind = "openai"
2407api_key = "existing"
2408
2409[models.legacy]
2410model = "gpt"
2411provider = "openai"
2412api_key = "legacy"
2413"#,
2414        );
2415
2416        let outcome = hub.migrate_model_config_if_needed().unwrap();
2417        assert!(matches!(
2418            outcome,
2419            crate::model_registry::ModelMigrationOutcome::Migrated { .. }
2420        ));
2421        let text = hub.read_config_toml().unwrap();
2422        assert!(text.contains("[providers.openai]"));
2423        assert!(text.contains("api_key = \"existing\""));
2424        assert!(text.contains("[providers.openai-2]"));
2425        assert!(text.contains("provider = \"openai-2\""));
2426    }
2427
2428    #[test]
2429    fn model_migration_preserves_unversioned_provider_reference() {
2430        let (dir, hub) = temp_hub();
2431        let text = r#"[providers.openai]
2432kind = "openai"
2433api_key = "existing"
2434
2435[models.current]
2436model = "gpt"
2437provider = "openai"
2438"#;
2439        write_config(&hub, text);
2440
2441        assert_eq!(
2442            hub.migrate_model_config_if_needed().unwrap(),
2443            crate::model_registry::ModelMigrationOutcome::NotNeeded
2444        );
2445        assert_eq!(hub.read_config_toml().unwrap(), text);
2446        assert!(!dir.path().join("config.toml.bak").exists());
2447    }
2448
2449    #[test]
2450    fn model_migration_rejects_invalid_and_future_versions() {
2451        for version in ["\"2\"", "3"] {
2452            let (_dir, hub) = temp_hub();
2453            let text = format!(
2454                "config_version = {version}\n[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n"
2455            );
2456            write_config(&hub, &text);
2457            assert!(hub.migrate_model_config_if_needed().is_err());
2458            assert_eq!(hub.read_config_toml().unwrap(), text);
2459        }
2460    }
2461
2462    #[test]
2463    fn model_migration_backup_conflict_preserves_source() {
2464        let (dir, hub) = temp_hub();
2465        let text = "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n";
2466        write_config(&hub, text);
2467        std::fs::write(dir.path().join("config.toml.bak"), "older backup").unwrap();
2468
2469        assert!(matches!(
2470            hub.migrate_model_config_if_needed(),
2471            Err(ConfigError::Invalid(message)) if message.contains("backup conflict")
2472        ));
2473        assert_eq!(hub.read_config_toml().unwrap(), text);
2474    }
2475
2476    #[cfg(unix)]
2477    #[test]
2478    fn model_migration_backup_is_owner_only() {
2479        use std::os::unix::fs::PermissionsExt;
2480        let (dir, hub) = temp_hub();
2481        write_config(
2482            &hub,
2483            "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\napi_key = \"secret\"\n",
2484        );
2485
2486        hub.migrate_model_config_if_needed().unwrap();
2487
2488        let mode = std::fs::metadata(dir.path().join("config.toml.bak"))
2489            .unwrap()
2490            .permissions()
2491            .mode()
2492            & 0o777;
2493        assert_eq!(mode, 0o600);
2494    }
2495
2496    #[test]
2497    fn duplicate_model_name_is_rejected_without_writing() {
2498        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2499        let (_dir, hub) = temp_hub();
2500        hub.upsert_model(model(None, "shared", "provider/a"))
2501            .unwrap();
2502        let before = hub.read_config_toml().unwrap();
2503
2504        let error = hub
2505            .upsert_model(model(None, "shared", "provider/b"))
2506            .unwrap_err();
2507
2508        assert!(matches!(
2509            error,
2510            ConfigError::NameConflict {
2511                domain: "models",
2512                ..
2513            }
2514        ));
2515        assert_eq!(hub.read_config_toml().unwrap(), before);
2516    }
2517
2518    #[test]
2519    fn model_rename_conflict_is_rejected_without_removing_source() {
2520        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2521        let (_dir, hub) = temp_hub();
2522        hub.upsert_model(model(None, "first", "provider/a"))
2523            .unwrap();
2524        hub.upsert_model(model(None, "second", "provider/b"))
2525            .unwrap();
2526        let before = hub.read_config_toml().unwrap();
2527
2528        let error = hub
2529            .upsert_model(model(Some("first"), "second", "provider/a"))
2530            .unwrap_err();
2531
2532        assert!(matches!(error, ConfigError::NameConflict { .. }));
2533        assert_eq!(hub.read_config_toml().unwrap(), before);
2534    }
2535
2536    #[test]
2537    fn model_and_alias_share_a_namespace() {
2538        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2539        let (_dir, hub) = temp_hub();
2540        hub.upsert_model(model(None, "smart", "provider/a"))
2541            .unwrap();
2542        assert!(matches!(
2543            hub.add_alias("smart", "provider/a"),
2544            Err(ConfigError::NameConflict {
2545                domain: "models",
2546                ..
2547            })
2548        ));
2549
2550        hub.add_alias("cheap", "provider/a").unwrap();
2551        assert!(matches!(
2552            hub.upsert_model(model(None, "cheap", "provider/b")),
2553            Err(ConfigError::NameConflict {
2554                domain: "alias",
2555                ..
2556            })
2557        ));
2558    }
2559
2560    #[test]
2561    fn alias_rename_conflict_is_rejected_without_removing_source() {
2562        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2563        let (_dir, hub) = temp_hub();
2564        hub.add_alias("first", "provider/a").unwrap();
2565        hub.add_alias("second", "provider/b").unwrap();
2566        let before = hub.read_config_toml().unwrap();
2567
2568        let error = hub
2569            .update_alias(Some("first"), "second", "provider/a")
2570            .unwrap_err();
2571
2572        assert!(matches!(
2573            error,
2574            ConfigError::NameConflict {
2575                domain: "alias",
2576                ..
2577            }
2578        ));
2579        assert_eq!(hub.read_config_toml().unwrap(), before);
2580    }
2581
2582    #[test]
2583    fn bind_default_model_rebinds_smart_atomically_and_keeps_cheap() {
2584        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2585        let (_dir, hub) = temp_hub();
2586        hub.add_alias("smart", "provider/old").unwrap();
2587
2588        hub.bind_default_model("provider/new").unwrap();
2589
2590        let text = hub.read_config_toml().unwrap();
2591        assert!(text.contains("[alias.smart]"));
2592        assert!(text.contains("model = \"provider/new\""));
2593        assert!(text.contains("[alias.cheap]"));
2594        assert!(text.contains("model = \"smart\""));
2595    }
2596
2597    #[test]
2598    fn bind_default_model_preserves_existing_cheap_alias() {
2599        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2600        let (_dir, hub) = temp_hub();
2601        hub.add_alias("smart", "provider/old").unwrap();
2602        hub.add_alias("cheap", "provider/custom-cheap").unwrap();
2603
2604        hub.bind_default_model("provider/new").unwrap();
2605
2606        let cfg = crate::model_registry::parse_config(&hub.read_config_toml().unwrap()).unwrap();
2607        assert_eq!(cfg.aliases["smart"].model, "provider/new");
2608        assert_eq!(cfg.aliases["cheap"].model, "provider/custom-cheap");
2609    }
2610
2611    #[test]
2612    fn bind_default_model_does_not_overwrite_smart_model() {
2613        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2614        let (_dir, hub) = temp_hub();
2615        hub.upsert_model(model(None, "smart", "provider/model"))
2616            .unwrap();
2617        let before = hub.read_config_toml().unwrap();
2618
2619        assert!(matches!(
2620            hub.bind_default_model("provider/new"),
2621            Err(ConfigError::NameConflict {
2622                domain: "models",
2623                ..
2624            })
2625        ));
2626        assert_eq!(hub.read_config_toml().unwrap(), before);
2627    }
2628
2629    #[test]
2630    fn distinct_names_may_use_the_same_provider_model_id() {
2631        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2632        let (_dir, hub) = temp_hub();
2633        hub.upsert_model(model(None, "first", "provider/shared"))
2634            .unwrap();
2635        hub.upsert_model(model(None, "second", "provider/shared"))
2636            .unwrap();
2637
2638        let text = hub.read_config_toml().unwrap();
2639        assert!(text.contains("[models.first]"));
2640        assert!(text.contains("[models.second]"));
2641    }
2642
2643    #[test]
2644    fn mcp_upsert_preserves_existing_json_servers_and_overrides_toml_by_name() {
2645        let (_dir, hub) = temp_hub();
2646        std::fs::write(
2647            hub.config_toml_path(),
2648            "[[mcp]]\nname = \"shared\"\ncommand = \"from-toml\"\n",
2649        )
2650        .unwrap();
2651        hub.save_mcp(&[crate::mcp::McpServerConfig::stdio(
2652            "existing",
2653            "existing-command",
2654            vec![],
2655            crate::tool::Tier::Two,
2656            30_000,
2657        )])
2658        .unwrap();
2659
2660        hub.upsert_mcp(crate::mcp::McpServerConfig::stdio(
2661            "shared",
2662            "from-json",
2663            vec![],
2664            crate::tool::Tier::Three,
2665            30_000,
2666        ))
2667        .unwrap();
2668
2669        let configs = hub.load_local_mcp();
2670        assert_eq!(configs.len(), 2);
2671        assert_eq!(
2672            configs
2673                .iter()
2674                .find(|cfg| cfg.name == "shared")
2675                .unwrap()
2676                .command,
2677            "from-json"
2678        );
2679        assert!(configs.iter().any(|cfg| cfg.name == "existing"));
2680        assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2681    }
2682
2683    #[test]
2684    fn mcp_toggle_toml_server_persists_json_override() {
2685        let (_dir, hub) = temp_hub();
2686        std::fs::write(
2687            hub.config_toml_path(),
2688            "[[mcp]]\nname = \"exa\"\ncommand = \"exa-mcp-server\"\n",
2689        )
2690        .unwrap();
2691
2692        assert!(hub.toggle_mcp("exa").unwrap());
2693
2694        let configs = hub.load_local_mcp();
2695        assert!(
2696            configs
2697                .iter()
2698                .find(|cfg| cfg.name == "exa")
2699                .unwrap()
2700                .disabled
2701        );
2702        assert!(hub.mcp_json_path().exists());
2703    }
2704
2705    #[test]
2706    fn mcp_remove_updates_json_atomically() {
2707        let (_dir, hub) = temp_hub();
2708        hub.save_mcp(&[
2709            crate::mcp::McpServerConfig::stdio(
2710                "first",
2711                "echo",
2712                vec![],
2713                crate::tool::Tier::Two,
2714                30_000,
2715            ),
2716            crate::mcp::McpServerConfig::stdio(
2717                "second",
2718                "ls",
2719                vec![],
2720                crate::tool::Tier::Two,
2721                30_000,
2722            ),
2723        ])
2724        .unwrap();
2725
2726        hub.remove_mcp("first").unwrap();
2727
2728        let configs = hub.load_local_mcp();
2729        assert_eq!(configs.len(), 1);
2730        assert_eq!(configs[0].name, "second");
2731        assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2732    }
2733
2734    #[test]
2735    fn alias_updates_preserve_comments_and_other_sections() {
2736        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2737        let (_dir, hub) = temp_hub();
2738        std::fs::write(
2739            hub.config_toml_path(),
2740            "# keep me\n[theme]\nname = \"dark\"\n\n[alias.old]\nmodel = \"provider/a\"\n",
2741        )
2742        .unwrap();
2743
2744        hub.update_alias(Some("old"), "new", "provider/b").unwrap();
2745
2746        let text = hub.read_config_toml().unwrap();
2747        assert!(text.contains("# keep me"));
2748        assert!(text.contains("[theme]"));
2749        assert!(text.contains("[alias.new]"));
2750        assert!(!text.contains("[alias.old]"));
2751    }
2752}