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::default()
1345        );
1346        write_config(
1347            &hub,
1348            "[tool_output]\nmax_lines = 7\nmax_bytes = 777\nmax_line_bytes = 111\n",
1349        );
1350        assert_eq!(
1351            hub.tool_output_budget().unwrap(),
1352            crate::tools::tool_output::ToolOutputBudget {
1353                max_lines: 7,
1354                max_bytes: 777,
1355                max_line_bytes: 111,
1356            }
1357        );
1358        let _ = dir;
1359    }
1360
1361    #[test]
1362    fn tool_output_budget_rejects_zero_values() {
1363        let (_dir, hub) = temp_hub();
1364        write_config(&hub, "[tool_output]\nmax_bytes = 0\n");
1365        assert!(hub.tool_output_budget().is_err());
1366    }
1367
1368    #[test]
1369    fn storage_config_merges_only_typed_storage_projection() {
1370        let (dir, hub) = temp_hub();
1371        std::fs::write(
1372            dir.path().join("config.toml"),
1373            "[storage]\nscope = \"local\"\n[theme]\nmode = \"dark\"\n",
1374        )
1375        .unwrap();
1376        let project = tempfile::tempdir().unwrap();
1377        std::fs::create_dir(project.path().join(".atman")).unwrap();
1378        std::fs::write(
1379            project.path().join(".atman/config.toml"),
1380            "[storage]\nscope = \"global\"\n[theme]\nmode = \"light\"\n",
1381        )
1382        .unwrap();
1383
1384        assert_eq!(
1385            hub.storage_config(Some(project.path())).scope,
1386            Some(crate::storage::StorageScope::Global)
1387        );
1388    }
1389
1390    #[test]
1391    fn storage_config_isolates_invalid_global_and_project_layers() {
1392        let (dir, hub) = temp_hub();
1393        let project = tempfile::tempdir().unwrap();
1394        std::fs::create_dir(project.path().join(".atman")).unwrap();
1395        std::fs::write(dir.path().join("config.toml"), "not valid [").unwrap();
1396        std::fs::write(
1397            project.path().join(".atman/config.toml"),
1398            "[storage]\nscope = \"local\"\n",
1399        )
1400        .unwrap();
1401        assert_eq!(
1402            hub.storage_config(Some(project.path())).scope,
1403            Some(crate::storage::StorageScope::Local)
1404        );
1405
1406        std::fs::write(
1407            dir.path().join("config.toml"),
1408            "[storage]\nscope = \"global\"\n",
1409        )
1410        .unwrap();
1411        std::fs::write(project.path().join(".atman/config.toml"), "not valid [").unwrap();
1412        assert_eq!(
1413            hub.storage_config(Some(project.path())).scope,
1414            Some(crate::storage::StorageScope::Global)
1415        );
1416    }
1417
1418    #[test]
1419    fn storage_config_treats_read_errors_as_empty_layers() {
1420        let (dir, hub) = temp_hub();
1421        std::fs::create_dir(dir.path().join("config.toml")).unwrap();
1422        let project = tempfile::tempdir().unwrap();
1423        std::fs::create_dir(project.path().join(".atman")).unwrap();
1424        std::fs::write(
1425            project.path().join(".atman/config.toml"),
1426            "[storage]\nscope = \"local\"\n",
1427        )
1428        .unwrap();
1429
1430        assert_eq!(
1431            hub.storage_config(Some(project.path())).scope,
1432            Some(crate::storage::StorageScope::Local)
1433        );
1434    }
1435
1436    fn write_config(hub: &ConfigHub, text: &str) {
1437        std::fs::write(hub.config_toml_path(), text).unwrap();
1438    }
1439
1440    #[test]
1441    fn append_dsl_route_creates_missing_file_and_escapes_trigger() {
1442        let (_dir, hub) = temp_hub();
1443        hub.append_dsl_route("review_code", "say \"hi\"\\now\n")
1444            .unwrap();
1445
1446        let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1447        let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1448        assert_eq!(parsed.routes.len(), 1);
1449        assert_eq!(parsed.routes[0].pattern, "say \"hi\"\\now\n");
1450        assert_eq!(parsed.routes[0].flow.name, "review_code");
1451    }
1452
1453    #[test]
1454    fn append_dsl_route_preserves_existing_source_exactly() {
1455        let (_dir, hub) = temp_hub();
1456        let original = "// keep this comment\nroute \"old \" { flow: old_flow }";
1457        std::fs::write(hub.routes_at_path(), original).unwrap();
1458
1459        hub.append_dsl_route("new_flow", "new ").unwrap();
1460
1461        assert_eq!(
1462            std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1463            format!("{original}\nroute \"new \" {{ flow: new_flow }}\n")
1464        );
1465    }
1466
1467    #[test]
1468    fn append_dsl_route_does_not_overwrite_invalid_existing_source() {
1469        let (_dir, hub) = temp_hub();
1470        let invalid = "route invalid";
1471        std::fs::write(hub.routes_at_path(), invalid).unwrap();
1472
1473        let error = hub.append_dsl_route("new_flow", "new ").unwrap_err();
1474
1475        assert!(error.to_string().contains("parse existing routes.at"));
1476        assert_eq!(
1477            std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1478            invalid
1479        );
1480    }
1481
1482    #[test]
1483    fn append_dsl_route_rejects_invalid_flow_without_writing() {
1484        let (_dir, hub) = temp_hub();
1485        let error = hub.append_dsl_route("bad-name", "new ").unwrap_err();
1486        assert!(error.to_string().contains("valid DSL identifier"));
1487        assert!(!hub.routes_at_path().exists());
1488    }
1489
1490    #[test]
1491    fn concurrent_dsl_route_appends_do_not_lose_updates() {
1492        let (_dir, hub) = temp_hub();
1493        let mut workers = Vec::new();
1494        for index in 0..12 {
1495            let hub = hub.clone();
1496            workers.push(std::thread::spawn(move || {
1497                hub.append_dsl_route(&format!("flow_{index}"), &format!("{index} "))
1498                    .unwrap();
1499            }));
1500        }
1501        for worker in workers {
1502            worker.join().unwrap();
1503        }
1504
1505        let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1506        let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1507        assert_eq!(parsed.routes.len(), 12);
1508        for index in 0..12 {
1509            assert!(parsed.routes.iter().any(|route| {
1510                route.flow.name == format!("flow_{index}") && route.pattern == format!("{index} ")
1511            }));
1512        }
1513        assert!(!std::fs::read_dir(hub.config_dir()).unwrap().any(|entry| {
1514            let name = entry.unwrap().file_name();
1515            let name = name.to_string_lossy();
1516            name.starts_with(".routes.at.") && name.ends_with(".tmp")
1517        }));
1518    }
1519
1520    #[test]
1521    fn append_dsl_route_waits_for_external_file_lock() {
1522        use fs2::FileExt;
1523        use std::sync::mpsc::TryRecvError;
1524
1525        let (_dir, hub) = temp_hub();
1526        let lock = std::fs::OpenOptions::new()
1527            .read(true)
1528            .write(true)
1529            .create(true)
1530            .truncate(false)
1531            .open(hub.config_dir().join(".routes.at.lock"))
1532            .unwrap();
1533        lock.lock_exclusive().unwrap();
1534
1535        let worker_hub = hub.clone();
1536        let (tx, rx) = std::sync::mpsc::channel();
1537        let worker = std::thread::spawn(move || {
1538            worker_hub.append_dsl_route("blocked", "wait ").unwrap();
1539            tx.send(()).unwrap();
1540        });
1541        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1542        FileExt::unlock(&lock).unwrap();
1543        rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1544        worker.join().unwrap();
1545    }
1546
1547    #[test]
1548    fn model_config_projection_handles_missing_valid_and_invalid_files() {
1549        let (_dir, hub) = temp_hub();
1550        assert!(hub.model_config().unwrap().is_none());
1551
1552        write_config(
1553            &hub,
1554            "[providers.openai]\nkind = \"openai\"\n[models.fast]\nmodel = \"gpt-4o-mini\"\n[alias.default]\nmodel = \"fast\"\n",
1555        );
1556        let config = hub.model_config().unwrap().unwrap();
1557        assert_eq!(config.providers["openai"].kind, "openai");
1558        assert_eq!(config.models["fast"].model, "gpt-4o-mini");
1559        assert_eq!(config.aliases["default"].model, "fast");
1560
1561        write_config(&hub, "[models]\n");
1562        assert!(hub.model_config().unwrap().is_none());
1563
1564        write_config(&hub, "[models\n");
1565        assert!(hub.model_config().is_err());
1566    }
1567
1568    #[test]
1569    fn theme_preference_defaults_to_auto_when_config_is_missing() {
1570        let (_dir, hub) = temp_hub();
1571
1572        assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1573    }
1574
1575    #[test]
1576    fn theme_preference_defaults_to_auto_when_mode_is_missing() {
1577        let (_dir, hub) = temp_hub();
1578        write_config(&hub, "[theme]\n");
1579
1580        assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1581    }
1582
1583    #[test]
1584    fn theme_preference_parses_supported_modes() {
1585        for (mode, expected) in [
1586            ("auto", ThemePreference::Auto),
1587            ("light", ThemePreference::Light),
1588            ("LiGhT", ThemePreference::Light),
1589            ("dark", ThemePreference::Dark),
1590        ] {
1591            let (_dir, hub) = temp_hub();
1592            write_config(&hub, &format!("[theme]\nmode = {mode:?}\n"));
1593
1594            assert_eq!(hub.theme_preference().unwrap(), expected);
1595        }
1596    }
1597
1598    #[test]
1599    fn theme_preference_rejects_unknown_mode() {
1600        let (_dir, hub) = temp_hub();
1601        write_config(&hub, "[theme]\nmode = \"sepia\"\n");
1602
1603        assert!(matches!(
1604            hub.theme_preference(),
1605            Err(ConfigError::Invalid(message)) if message.contains("theme.mode")
1606        ));
1607    }
1608
1609    fn auth_provider(id: &str) -> crate::auth_store::StoredProvider {
1610        crate::auth_store::StoredProvider {
1611            id: id.into(),
1612            name: id.into(),
1613            kind: crate::auth_store::ProviderKind::Codex,
1614            access_token: "old-access".into(),
1615            refresh_token: Some("old-refresh".into()),
1616            expires_at: 1,
1617            account: Some("old-account".into()),
1618            enabled: true,
1619            model_cache: None,
1620        }
1621    }
1622
1623    #[test]
1624    fn auth_transactions_preserve_independent_concurrent_updates() {
1625        let dir = tempfile::tempdir().unwrap();
1626        let path = dir.path().join("auth.json");
1627        let hub = ConfigHub::from_auth_path(&path);
1628        hub.add_auth_provider(auth_provider("provider")).unwrap();
1629
1630        let cache_hub = hub.clone();
1631        let cache = std::thread::spawn(move || {
1632            cache_hub
1633                .update_auth_model_cache(
1634                    "provider",
1635                    crate::auth_store::ModelCache {
1636                        fetched_at: 10,
1637                        models: vec![crate::auth_store::CachedModel {
1638                            slug: "cached-model".into(),
1639                            context_budget: Some(8192),
1640                            thinking: true,
1641                        }],
1642                    },
1643                )
1644                .unwrap();
1645        });
1646        let token_hub = hub.clone();
1647        let tokens = std::thread::spawn(move || {
1648            token_hub
1649                .update_auth_tokens(
1650                    "provider",
1651                    AuthTokenUpdate {
1652                        access_token: "new-access".into(),
1653                        refresh_token: Some("new-refresh".into()),
1654                        expires_at: 99,
1655                        account: None,
1656                    },
1657                )
1658                .unwrap();
1659        });
1660        let enabled_hub = hub.clone();
1661        let enabled = std::thread::spawn(move || {
1662            enabled_hub
1663                .set_auth_provider_enabled("provider", false)
1664                .unwrap();
1665        });
1666        cache.join().unwrap();
1667        tokens.join().unwrap();
1668        enabled.join().unwrap();
1669
1670        let store = hub.load_auth().unwrap();
1671        let provider = &store.providers[0];
1672        assert_eq!(provider.access_token, "new-access");
1673        assert_eq!(provider.refresh_token.as_deref(), Some("new-refresh"));
1674        assert_eq!(provider.expires_at, 99);
1675        assert_eq!(provider.account.as_deref(), Some("old-account"));
1676        assert!(!provider.enabled);
1677        assert_eq!(
1678            provider.model_cache.as_ref().unwrap().models[0].slug,
1679            "cached-model"
1680        );
1681        #[cfg(unix)]
1682        {
1683            use std::os::unix::fs::PermissionsExt;
1684            assert_eq!(
1685                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1686                0o600
1687            );
1688        }
1689        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1690            let name = entry.unwrap().file_name();
1691            let name = name.to_string_lossy();
1692            name.starts_with(".auth.json.") && name.ends_with(".tmp")
1693        }));
1694    }
1695
1696    #[test]
1697    fn auth_transaction_waits_for_external_file_lock() {
1698        use fs2::FileExt;
1699        use std::sync::mpsc::TryRecvError;
1700
1701        let dir = tempfile::tempdir().unwrap();
1702        let path = dir.path().join("auth.json");
1703        let lock_path = dir.path().join(".auth.json.lock");
1704        let lock = std::fs::OpenOptions::new()
1705            .read(true)
1706            .write(true)
1707            .create(true)
1708            .truncate(false)
1709            .open(lock_path)
1710            .unwrap();
1711        lock.lock_exclusive().unwrap();
1712
1713        let hub = ConfigHub::from_auth_path(&path);
1714        let (tx, rx) = std::sync::mpsc::channel();
1715        let worker = std::thread::spawn(move || {
1716            hub.add_auth_provider(auth_provider("blocked")).unwrap();
1717            tx.send(()).unwrap();
1718        });
1719        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1720        FileExt::unlock(&lock).unwrap();
1721        rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1722        worker.join().unwrap();
1723    }
1724
1725    #[test]
1726    fn auth_transaction_error_rolls_back_without_writing() {
1727        let dir = tempfile::tempdir().unwrap();
1728        let path = dir.path().join("auth.json");
1729        let hub = ConfigHub::from_auth_path(&path);
1730        hub.add_auth_provider(auth_provider("original")).unwrap();
1731        let before = std::fs::read(&path).unwrap();
1732
1733        let result: Result<(), ConfigError> = hub.update_auth(|store| {
1734            store.providers.push(auth_provider("discarded"));
1735            Err(ConfigError::Invalid("reject mutation".into()))
1736        });
1737        assert!(result.is_err());
1738        assert_eq!(std::fs::read(&path).unwrap(), before);
1739    }
1740
1741    #[test]
1742    fn auth_transaction_does_not_overwrite_corrupt_json() {
1743        let dir = tempfile::tempdir().unwrap();
1744        let path = dir.path().join("auth.json");
1745        let corrupt = b"{not-json";
1746        std::fs::write(&path, corrupt).unwrap();
1747        let hub = ConfigHub::from_auth_path(&path);
1748
1749        let err = hub.add_auth_provider(auth_provider("new")).unwrap_err();
1750        assert!(err.to_string().contains("parse"));
1751        assert_eq!(std::fs::read(&path).unwrap(), corrupt);
1752    }
1753
1754    #[test]
1755    fn auth_load_defaults_when_file_is_missing() {
1756        let dir = tempfile::tempdir().unwrap();
1757        let hub = ConfigHub::from_auth_path(dir.path().join("auth.json"));
1758        assert!(hub.load_auth().unwrap().providers.is_empty());
1759    }
1760
1761    #[test]
1762    fn daemon_config_initializes_reuses_and_rotates_atomically() {
1763        let dir = tempfile::tempdir().unwrap();
1764        let path = dir.path().join("daemon.toml");
1765        let hub = ConfigHub::from_daemon_config_path(&path);
1766
1767        let first = hub.load_or_init_daemon_config().unwrap();
1768        assert_eq!(first.auth_token.len(), 64);
1769        assert!(first.auth_token.chars().all(|c| c.is_ascii_hexdigit()));
1770        let second = hub.load_or_init_daemon_config().unwrap();
1771        assert_eq!(second, first);
1772        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1773            let name = entry.unwrap().file_name();
1774            let name = name.to_string_lossy();
1775            name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1776        }));
1777        #[cfg(unix)]
1778        {
1779            use std::os::unix::fs::PermissionsExt;
1780            assert_eq!(
1781                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1782                0o600
1783            );
1784        }
1785
1786        let rotated = hub.rotate_daemon_config().unwrap();
1787        assert_ne!(rotated.auth_token, first.auth_token);
1788        assert_eq!(hub.load_or_init_daemon_config().unwrap(), rotated);
1789        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1790            let name = entry.unwrap().file_name();
1791            let name = name.to_string_lossy();
1792            name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1793        }));
1794    }
1795
1796    #[test]
1797    fn daemon_config_waits_for_external_file_lock() {
1798        use std::sync::mpsc::TryRecvError;
1799
1800        let dir = tempfile::tempdir().unwrap();
1801        let path = dir.path().join("custom-daemon.toml");
1802        let lock = lock_file(&lock_path_for(&path)).unwrap();
1803        let hub = ConfigHub::from_daemon_config_path(&path);
1804        let (tx, rx) = std::sync::mpsc::channel();
1805        let worker = std::thread::spawn(move || {
1806            tx.send(hub.load_or_init_daemon_config()).unwrap();
1807        });
1808
1809        std::thread::sleep(std::time::Duration::from_millis(25));
1810        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1811        lock.unlock().unwrap();
1812        assert!(
1813            rx.recv_timeout(std::time::Duration::from_secs(1))
1814                .unwrap()
1815                .is_ok()
1816        );
1817        worker.join().unwrap();
1818    }
1819
1820    #[cfg(unix)]
1821    #[test]
1822    fn legacy_daemon_config_uses_custom_path_and_owner_only_permissions() {
1823        use std::os::unix::fs::PermissionsExt;
1824
1825        let config = tempfile::tempdir().unwrap();
1826        let data = tempfile::tempdir().unwrap();
1827        let daemon_path = config.path().join("daemon/custom.toml");
1828        std::fs::write(data.path().join("daemon.toml"), "auth_token = \"legacy\"\n").unwrap();
1829
1830        let report = ConfigHub::from_config_dir(config.path())
1831            .with_daemon_config_path(&daemon_path)
1832            .migrate_legacy_layout(data.path())
1833            .unwrap()
1834            .unwrap();
1835
1836        assert!(report.moved.iter().any(|path| path == "daemon.toml"));
1837        assert_eq!(
1838            std::fs::read_to_string(&daemon_path).unwrap(),
1839            "auth_token = \"legacy\"\n"
1840        );
1841        assert!(!config.path().join("daemon.toml").exists());
1842        assert_eq!(
1843            std::fs::metadata(&daemon_path)
1844                .unwrap()
1845                .permissions()
1846                .mode()
1847                & 0o777,
1848            0o600
1849        );
1850    }
1851
1852    #[test]
1853    fn daemon_config_rotation_requires_existing_file() {
1854        let dir = tempfile::tempdir().unwrap();
1855        let path = dir.path().join("missing.toml");
1856        let err = ConfigHub::from_daemon_config_path(&path)
1857            .rotate_daemon_config()
1858            .unwrap_err();
1859        assert!(err.to_string().contains("no daemon config"));
1860    }
1861
1862    #[test]
1863    fn web_configs_default_when_config_or_section_is_missing() {
1864        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1865            let (_dir, hub) = temp_hub();
1866            if let Some(text) = text {
1867                write_config(&hub, text);
1868            }
1869
1870            let fetch = hub.web_fetch_config().unwrap();
1871            assert_eq!(fetch.max_bytes, 1_000_000);
1872            assert!(fetch.url_allowlist.is_empty());
1873            assert!(fetch.url_denylist.is_empty());
1874            let search = hub.web_search_config().unwrap();
1875            assert_eq!(search.provider_name(), "tavily");
1876        }
1877    }
1878
1879    #[test]
1880    fn web_configs_parse_fetch_and_search_fields() {
1881        let (_dir, hub) = temp_hub();
1882        write_config(
1883            &hub,
1884            r#"
1885[web]
1886max_bytes = 4096
1887url_allowlist = ["https://ok.example"]
1888url_denylist = ["https://ok.example/private"]
1889
1890[web.search]
1891provider = "searxng"
1892base_url = "http://localhost:8080"
1893max_results = 6
1894"#,
1895        );
1896
1897        let fetch = hub.web_fetch_config().unwrap();
1898        assert_eq!(fetch.max_bytes, 4096);
1899        assert_eq!(fetch.url_allowlist, vec!["https://ok.example"]);
1900        assert_eq!(fetch.url_denylist, vec!["https://ok.example/private"]);
1901        assert_eq!(hub.web_search_config().unwrap().provider_name(), "searxng");
1902    }
1903
1904    #[test]
1905    fn web_fetch_schema_error_does_not_break_valid_search() {
1906        let (_dir, hub) = temp_hub();
1907        write_config(
1908            &hub,
1909            "[web]\nmax_bytes = \"large\"\n[web.search]\nprovider = \"none\"\n",
1910        );
1911
1912        assert!(matches!(
1913            hub.web_fetch_config(),
1914            Err(ConfigError::Invalid(_))
1915        ));
1916        assert_eq!(hub.web_search_config().unwrap().provider_name(), "none");
1917    }
1918
1919    #[test]
1920    fn web_search_schema_error_does_not_break_valid_fetch() {
1921        let (_dir, hub) = temp_hub();
1922        write_config(
1923            &hub,
1924            "[web]\nmax_bytes = 2048\n[web.search]\nprovider = \"unknown\"\n",
1925        );
1926
1927        assert_eq!(hub.web_fetch_config().unwrap().max_bytes, 2048);
1928        assert!(matches!(
1929            hub.web_search_config(),
1930            Err(ConfigError::Invalid(_))
1931        ));
1932    }
1933
1934    #[test]
1935    fn trust_config_defaults_when_config_or_section_is_missing() {
1936        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1937            let (_dir, hub) = temp_hub();
1938            if let Some(text) = text {
1939                write_config(&hub, text);
1940            }
1941
1942            let config = hub.trust_config().unwrap();
1943            assert_eq!(config.mode, crate::trust::TrustMode::Steady);
1944            assert_eq!(config.theme, crate::trust::Theme::Default);
1945            assert_eq!(config.outside, crate::trust::OutsideBehavior::Approve);
1946        }
1947    }
1948
1949    #[test]
1950    fn trust_config_parses_mode_theme_and_outside() {
1951        let (_dir, hub) = temp_hub();
1952        write_config(
1953            &hub,
1954            "[trust]\nmode = \"eager\"\ntheme = \"weather\"\noutside = \"deny\"\n",
1955        );
1956
1957        let config = hub.trust_config().unwrap();
1958        assert_eq!(config.mode, crate::trust::TrustMode::Eager);
1959        assert_eq!(config.theme, crate::trust::Theme::Weather);
1960        assert_eq!(config.outside, crate::trust::OutsideBehavior::Deny);
1961    }
1962
1963    #[test]
1964    fn trust_config_rejects_invalid_enum() {
1965        let (_dir, hub) = temp_hub();
1966        write_config(&hub, "[trust]\noutside = \"sometimes\"\n");
1967
1968        assert!(matches!(
1969            hub.trust_config(),
1970            Err(ConfigError::Invalid(message)) if message.contains("parse trust config")
1971        ));
1972    }
1973
1974    #[test]
1975    fn preview_config_defaults_when_config_or_section_is_missing() {
1976        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1977            let (_dir, hub) = temp_hub();
1978            if let Some(text) = text {
1979                write_config(&hub, text);
1980            }
1981
1982            let config = hub.preview_config().unwrap();
1983            let expected = crate::tools::preview::PreviewConfig::default();
1984            assert_eq!(config.base_url, expected.base_url);
1985            assert_eq!(config.timeout_ms, expected.timeout_ms);
1986            assert_eq!(config.project_abs_path, expected.project_abs_path);
1987            assert_eq!(config.project_hint_slug, expected.project_hint_slug);
1988            assert_eq!(config.max_body_bytes, expected.max_body_bytes);
1989        }
1990    }
1991
1992    #[test]
1993    fn preview_config_parses_all_supported_fields() {
1994        let (_dir, hub) = temp_hub();
1995        write_config(
1996            &hub,
1997            r#"
1998[preview]
1999base_url = "http://127.0.0.1:9000"
2000timeout_ms = 4500
2001project_abs_path = "/tmp/project"
2002project_hint_slug = "project"
2003max_body_bytes = 2048
2004"#,
2005        );
2006
2007        let config = hub.preview_config().unwrap();
2008        assert_eq!(config.base_url, "http://127.0.0.1:9000");
2009        assert_eq!(config.timeout_ms, 4500);
2010        assert_eq!(config.project_abs_path, "/tmp/project");
2011        assert_eq!(config.project_hint_slug.as_deref(), Some("project"));
2012        assert_eq!(config.max_body_bytes, 2048);
2013    }
2014
2015    #[test]
2016    fn preview_config_rejects_invalid_schema() {
2017        let (_dir, hub) = temp_hub();
2018        write_config(&hub, "[preview]\ntimeout_ms = \"slow\"\n");
2019
2020        assert!(matches!(
2021            hub.preview_config(),
2022            Err(ConfigError::Invalid(message)) if message.contains("parse preview config")
2023        ));
2024    }
2025
2026    #[test]
2027    fn sandbox_config_defaults_when_config_or_section_is_missing() {
2028        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
2029            let (_dir, hub) = temp_hub();
2030            if let Some(text) = text {
2031                write_config(&hub, text);
2032            }
2033
2034            assert_eq!(hub.sandbox_config().unwrap(), SandboxConfig::default());
2035        }
2036    }
2037
2038    #[test]
2039    fn sandbox_config_preserves_paths_and_defaults_missing_enabled() {
2040        let (_dir, hub) = temp_hub();
2041        write_config(
2042            &hub,
2043            r#"
2044[sandbox]
2045strict = true
2046extra_read = ["../read"]
2047extra_write = ["/tmp/write"]
2048template_path = "profiles/custom.sb"
2049allow_network = true
2050"#,
2051        );
2052
2053        assert_eq!(
2054            hub.sandbox_config().unwrap(),
2055            SandboxConfig {
2056                enabled: true,
2057                strict: true,
2058                extra_read: vec![PathBuf::from("../read")],
2059                extra_write: vec![PathBuf::from("/tmp/write")],
2060                template_path: Some(PathBuf::from("profiles/custom.sb")),
2061                allow_network: true,
2062            }
2063        );
2064    }
2065
2066    #[test]
2067    fn sandbox_config_allows_explicit_opt_out() {
2068        let (_dir, hub) = temp_hub();
2069        write_config(&hub, "[sandbox]\nenabled = false\n");
2070
2071        assert!(!hub.sandbox_config().unwrap().enabled);
2072    }
2073
2074    #[test]
2075    fn sandbox_config_rejects_invalid_schema() {
2076        let (_dir, hub) = temp_hub();
2077        write_config(&hub, "[sandbox]\nextra_read = \"/tmp\"\n");
2078
2079        assert!(matches!(
2080            hub.sandbox_config(),
2081            Err(ConfigError::Invalid(message)) if message.contains("parse sandbox config")
2082        ));
2083    }
2084
2085    #[test]
2086    fn redact_config_defaults_when_config_is_missing_or_section_is_missing() {
2087        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
2088            let (_dir, hub) = temp_hub();
2089            if let Some(text) = text {
2090                write_config(&hub, text);
2091            }
2092
2093            assert_eq!(hub.redact_config().unwrap(), RedactConfig::default());
2094        }
2095    }
2096
2097    #[test]
2098    fn redact_config_parses_mode_patterns_and_allowlist() {
2099        let (_dir, hub) = temp_hub();
2100        write_config(
2101            &hub,
2102            r#"
2103[redact]
2104enabled = true
2105mode = "partial"
2106allowlist = ["safe@example.com"]
2107custom_patterns = [{ kind = "ticket", regex = "T-[0-9]+" }]
2108"#,
2109        );
2110
2111        assert_eq!(
2112            hub.redact_config().unwrap(),
2113            RedactConfig {
2114                enabled: true,
2115                partial: true,
2116                allowlist: vec!["safe@example.com".into()],
2117                custom_patterns: vec![("ticket".into(), "T-[0-9]+".into())],
2118            }
2119        );
2120    }
2121
2122    #[test]
2123    fn redact_config_treats_unknown_mode_as_full() {
2124        let (_dir, hub) = temp_hub();
2125        write_config(&hub, "[redact]\nenabled = true\nmode = \"unknown\"\n");
2126
2127        let config = hub.redact_config().unwrap();
2128        assert!(config.enabled);
2129        assert!(!config.partial);
2130    }
2131
2132    #[test]
2133    fn redact_config_rejects_invalid_schema() {
2134        let (_dir, hub) = temp_hub();
2135        write_config(&hub, "[redact]\nenabled = \"yes\"\n");
2136
2137        assert!(matches!(
2138            hub.redact_config(),
2139            Err(ConfigError::Invalid(message)) if message.contains("parse redact config")
2140        ));
2141    }
2142
2143    #[test]
2144    fn interjection_mode_defaults_to_none_when_config_or_value_is_missing() {
2145        for text in [
2146            None,
2147            Some("[theme]\nmode = \"dark\"\n"),
2148            Some("[interjection]\n"),
2149        ] {
2150            let (_dir, hub) = temp_hub();
2151            if let Some(text) = text {
2152                write_config(&hub, text);
2153            }
2154
2155            assert_eq!(hub.interjection_mode().unwrap(), None);
2156        }
2157    }
2158
2159    #[test]
2160    fn interjection_mode_parses_supported_and_unknown_values() {
2161        for (value, expected) in [
2162            ("off", InterjectionMode::Off),
2163            ("rule", InterjectionMode::Rule),
2164            ("llm", InterjectionMode::Llm),
2165            ("custom", InterjectionMode::Unknown("custom".into())),
2166        ] {
2167            let (_dir, hub) = temp_hub();
2168            write_config(&hub, &format!("[interjection]\nclassifier = {value:?}\n"));
2169
2170            assert_eq!(hub.interjection_mode().unwrap(), Some(expected));
2171        }
2172    }
2173
2174    #[test]
2175    fn interjection_mode_rejects_non_string_value() {
2176        let (_dir, hub) = temp_hub();
2177        write_config(&hub, "[interjection]\nclassifier = true\n");
2178
2179        assert!(matches!(
2180            hub.interjection_mode(),
2181            Err(ConfigError::Invalid(message)) if message.contains("interjection.classifier")
2182        ));
2183    }
2184
2185    #[test]
2186    fn suggest_model_defaults_to_none_when_config_or_value_is_missing() {
2187        for text in [
2188            None,
2189            Some("[theme]\nmode = \"dark\"\n"),
2190            Some("[suggest]\n"),
2191        ] {
2192            let (_dir, hub) = temp_hub();
2193            if let Some(text) = text {
2194                write_config(&hub, text);
2195            }
2196
2197            assert_eq!(hub.suggest_model().unwrap(), None);
2198        }
2199    }
2200
2201    #[test]
2202    fn suggest_model_returns_configured_string_including_empty() {
2203        for value in ["smart", ""] {
2204            let (_dir, hub) = temp_hub();
2205            write_config(&hub, &format!("[suggest]\nmodel = {value:?}\n"));
2206
2207            assert_eq!(hub.suggest_model().unwrap().as_deref(), Some(value));
2208        }
2209    }
2210
2211    #[test]
2212    fn suggest_model_rejects_non_string_value() {
2213        let (_dir, hub) = temp_hub();
2214        write_config(&hub, "[suggest]\nmodel = 42\n");
2215
2216        assert!(matches!(
2217            hub.suggest_model(),
2218            Err(ConfigError::Invalid(message)) if message.contains("suggest.model")
2219        ));
2220    }
2221
2222    #[test]
2223    fn compact_review_mode_defaults_to_none_when_config_or_value_is_missing() {
2224        for text in [
2225            None,
2226            Some("[theme]\nmode = \"dark\"\n"),
2227            Some("[compaction]\n"),
2228        ] {
2229            let (_dir, hub) = temp_hub();
2230            if let Some(text) = text {
2231                write_config(&hub, text);
2232            }
2233
2234            assert_eq!(hub.compact_review_mode().unwrap(), None);
2235        }
2236    }
2237
2238    #[test]
2239    fn compact_review_mode_parses_supported_values() {
2240        for (value, expected) in [
2241            ("always", crate::CompactReviewMode::Always),
2242            ("manual-only", crate::CompactReviewMode::ManualOnly),
2243            ("manual_only", crate::CompactReviewMode::ManualOnly),
2244            ("never", crate::CompactReviewMode::Never),
2245        ] {
2246            let (_dir, hub) = temp_hub();
2247            write_config(&hub, &format!("[compaction]\nreview = {value:?}\n"));
2248
2249            assert_eq!(hub.compact_review_mode().unwrap(), Some(expected));
2250        }
2251    }
2252
2253    #[test]
2254    fn compact_review_mode_rejects_unknown_or_non_string_value() {
2255        for value in ["\"sometimes\"", "true"] {
2256            let (_dir, hub) = temp_hub();
2257            write_config(&hub, &format!("[compaction]\nreview = {value}\n"));
2258
2259            assert!(matches!(
2260                hub.compact_review_mode(),
2261                Err(ConfigError::Invalid(message)) if message.contains("compaction.review")
2262            ));
2263        }
2264    }
2265
2266    #[test]
2267    fn auto_snapshot_defaults_to_none_when_config_or_value_is_missing() {
2268        for text in [
2269            None,
2270            Some("[theme]\nmode = \"dark\"\n"),
2271            Some("[registry]\n"),
2272        ] {
2273            let (_dir, hub) = temp_hub();
2274            if let Some(text) = text {
2275                write_config(&hub, text);
2276            }
2277
2278            assert_eq!(hub.auto_snapshot().unwrap(), None);
2279        }
2280    }
2281
2282    #[test]
2283    fn auto_snapshot_reads_boolean_values() {
2284        for value in [true, false] {
2285            let (_dir, hub) = temp_hub();
2286            write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2287
2288            assert_eq!(hub.auto_snapshot().unwrap(), Some(value));
2289        }
2290    }
2291
2292    #[test]
2293    fn auto_snapshot_reads_integer_values() {
2294        for (value, expected) in [(1, true), (0, false)] {
2295            let (_dir, hub) = temp_hub();
2296            write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2297
2298            assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2299        }
2300    }
2301
2302    #[test]
2303    fn auto_snapshot_only_enables_exact_true_string() {
2304        for (value, expected) in [("true", true), ("yes", false)] {
2305            let (_dir, hub) = temp_hub();
2306            write_config(&hub, &format!("[registry]\nauto_snapshot = {value:?}\n"));
2307
2308            assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2309        }
2310    }
2311
2312    #[test]
2313    fn auto_snapshot_rejects_unsupported_type() {
2314        let (_dir, hub) = temp_hub();
2315        write_config(&hub, "[registry]\nauto_snapshot = [true]\n");
2316
2317        assert!(matches!(
2318            hub.auto_snapshot(),
2319            Err(ConfigError::Invalid(message)) if message.contains("registry.auto_snapshot")
2320        ));
2321    }
2322
2323    #[test]
2324    fn fs_access_mode_defaults_to_none_when_config_is_missing() {
2325        let (_dir, hub) = temp_hub();
2326
2327        assert_eq!(hub.fs_access_mode().unwrap(), None);
2328    }
2329
2330    #[test]
2331    fn fs_access_mode_defaults_to_none_when_section_or_mode_is_missing() {
2332        for text in ["[theme]\nmode = \"dark\"\n", "[fs_access]\n"] {
2333            let (_dir, hub) = temp_hub();
2334            write_config(&hub, text);
2335
2336            assert_eq!(hub.fs_access_mode().unwrap(), None);
2337        }
2338    }
2339
2340    #[test]
2341    fn fs_access_mode_parses_canonical_and_alias_values() {
2342        for (mode, expected) in [
2343            ("read-only", crate::fs_access::FsAccessMode::ReadOnly),
2344            ("ws", crate::fs_access::FsAccessMode::WorkspaceWrite),
2345            (
2346                "danger-full-access",
2347                crate::fs_access::FsAccessMode::DangerFullAccess,
2348            ),
2349        ] {
2350            let (_dir, hub) = temp_hub();
2351            write_config(&hub, &format!("[fs_access]\nmode = {mode:?}\n"));
2352
2353            assert_eq!(hub.fs_access_mode().unwrap(), Some(expected));
2354        }
2355    }
2356
2357    #[test]
2358    fn fs_access_mode_rejects_unknown_mode() {
2359        let (_dir, hub) = temp_hub();
2360        write_config(&hub, "[fs_access]\nmode = \"chaos\"\n");
2361
2362        assert!(matches!(
2363            hub.fs_access_mode(),
2364            Err(ConfigError::Invalid(message)) if message.contains("unknown fs access mode")
2365        ));
2366    }
2367
2368    #[test]
2369    fn fs_access_mode_rejects_non_string_mode() {
2370        let (_dir, hub) = temp_hub();
2371        write_config(&hub, "[fs_access]\nmode = true\n");
2372
2373        assert!(matches!(
2374            hub.fs_access_mode(),
2375            Err(ConfigError::Invalid(message)) if message.contains("fs_access.mode")
2376        ));
2377    }
2378
2379    fn model<'a>(
2380        old_name: Option<&'a str>,
2381        name: &'a str,
2382        model: &'a str,
2383    ) -> ModelConfigUpdate<'a> {
2384        ModelConfigUpdate {
2385            old_name,
2386            name,
2387            model,
2388            provider: Some("test"),
2389            context_budget: 100_000,
2390            thinking: false,
2391            max_tokens: None,
2392            enabled: true,
2393        }
2394    }
2395
2396    #[test]
2397    fn model_migration_preserves_existing_provider_name() {
2398        let (_dir, hub) = temp_hub();
2399        write_config(
2400            &hub,
2401            r#"[providers.openai]
2402kind = "openai"
2403api_key = "existing"
2404
2405[models.legacy]
2406model = "gpt"
2407provider = "openai"
2408api_key = "legacy"
2409"#,
2410        );
2411
2412        let outcome = hub.migrate_model_config_if_needed().unwrap();
2413        assert!(matches!(
2414            outcome,
2415            crate::model_registry::ModelMigrationOutcome::Migrated { .. }
2416        ));
2417        let text = hub.read_config_toml().unwrap();
2418        assert!(text.contains("[providers.openai]"));
2419        assert!(text.contains("api_key = \"existing\""));
2420        assert!(text.contains("[providers.openai-2]"));
2421        assert!(text.contains("provider = \"openai-2\""));
2422    }
2423
2424    #[test]
2425    fn model_migration_preserves_unversioned_provider_reference() {
2426        let (dir, hub) = temp_hub();
2427        let text = r#"[providers.openai]
2428kind = "openai"
2429api_key = "existing"
2430
2431[models.current]
2432model = "gpt"
2433provider = "openai"
2434"#;
2435        write_config(&hub, text);
2436
2437        assert_eq!(
2438            hub.migrate_model_config_if_needed().unwrap(),
2439            crate::model_registry::ModelMigrationOutcome::NotNeeded
2440        );
2441        assert_eq!(hub.read_config_toml().unwrap(), text);
2442        assert!(!dir.path().join("config.toml.bak").exists());
2443    }
2444
2445    #[test]
2446    fn model_migration_rejects_invalid_and_future_versions() {
2447        for version in ["\"2\"", "3"] {
2448            let (_dir, hub) = temp_hub();
2449            let text = format!(
2450                "config_version = {version}\n[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n"
2451            );
2452            write_config(&hub, &text);
2453            assert!(hub.migrate_model_config_if_needed().is_err());
2454            assert_eq!(hub.read_config_toml().unwrap(), text);
2455        }
2456    }
2457
2458    #[test]
2459    fn model_migration_backup_conflict_preserves_source() {
2460        let (dir, hub) = temp_hub();
2461        let text = "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n";
2462        write_config(&hub, text);
2463        std::fs::write(dir.path().join("config.toml.bak"), "older backup").unwrap();
2464
2465        assert!(matches!(
2466            hub.migrate_model_config_if_needed(),
2467            Err(ConfigError::Invalid(message)) if message.contains("backup conflict")
2468        ));
2469        assert_eq!(hub.read_config_toml().unwrap(), text);
2470    }
2471
2472    #[cfg(unix)]
2473    #[test]
2474    fn model_migration_backup_is_owner_only() {
2475        use std::os::unix::fs::PermissionsExt;
2476        let (dir, hub) = temp_hub();
2477        write_config(
2478            &hub,
2479            "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\napi_key = \"secret\"\n",
2480        );
2481
2482        hub.migrate_model_config_if_needed().unwrap();
2483
2484        let mode = std::fs::metadata(dir.path().join("config.toml.bak"))
2485            .unwrap()
2486            .permissions()
2487            .mode()
2488            & 0o777;
2489        assert_eq!(mode, 0o600);
2490    }
2491
2492    #[test]
2493    fn duplicate_model_name_is_rejected_without_writing() {
2494        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2495        let (_dir, hub) = temp_hub();
2496        hub.upsert_model(model(None, "shared", "provider/a"))
2497            .unwrap();
2498        let before = hub.read_config_toml().unwrap();
2499
2500        let error = hub
2501            .upsert_model(model(None, "shared", "provider/b"))
2502            .unwrap_err();
2503
2504        assert!(matches!(
2505            error,
2506            ConfigError::NameConflict {
2507                domain: "models",
2508                ..
2509            }
2510        ));
2511        assert_eq!(hub.read_config_toml().unwrap(), before);
2512    }
2513
2514    #[test]
2515    fn model_rename_conflict_is_rejected_without_removing_source() {
2516        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2517        let (_dir, hub) = temp_hub();
2518        hub.upsert_model(model(None, "first", "provider/a"))
2519            .unwrap();
2520        hub.upsert_model(model(None, "second", "provider/b"))
2521            .unwrap();
2522        let before = hub.read_config_toml().unwrap();
2523
2524        let error = hub
2525            .upsert_model(model(Some("first"), "second", "provider/a"))
2526            .unwrap_err();
2527
2528        assert!(matches!(error, ConfigError::NameConflict { .. }));
2529        assert_eq!(hub.read_config_toml().unwrap(), before);
2530    }
2531
2532    #[test]
2533    fn model_and_alias_share_a_namespace() {
2534        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2535        let (_dir, hub) = temp_hub();
2536        hub.upsert_model(model(None, "smart", "provider/a"))
2537            .unwrap();
2538        assert!(matches!(
2539            hub.add_alias("smart", "provider/a"),
2540            Err(ConfigError::NameConflict {
2541                domain: "models",
2542                ..
2543            })
2544        ));
2545
2546        hub.add_alias("cheap", "provider/a").unwrap();
2547        assert!(matches!(
2548            hub.upsert_model(model(None, "cheap", "provider/b")),
2549            Err(ConfigError::NameConflict {
2550                domain: "alias",
2551                ..
2552            })
2553        ));
2554    }
2555
2556    #[test]
2557    fn alias_rename_conflict_is_rejected_without_removing_source() {
2558        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2559        let (_dir, hub) = temp_hub();
2560        hub.add_alias("first", "provider/a").unwrap();
2561        hub.add_alias("second", "provider/b").unwrap();
2562        let before = hub.read_config_toml().unwrap();
2563
2564        let error = hub
2565            .update_alias(Some("first"), "second", "provider/a")
2566            .unwrap_err();
2567
2568        assert!(matches!(
2569            error,
2570            ConfigError::NameConflict {
2571                domain: "alias",
2572                ..
2573            }
2574        ));
2575        assert_eq!(hub.read_config_toml().unwrap(), before);
2576    }
2577
2578    #[test]
2579    fn bind_default_model_rebinds_smart_atomically_and_keeps_cheap() {
2580        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2581        let (_dir, hub) = temp_hub();
2582        hub.add_alias("smart", "provider/old").unwrap();
2583
2584        hub.bind_default_model("provider/new").unwrap();
2585
2586        let text = hub.read_config_toml().unwrap();
2587        assert!(text.contains("[alias.smart]"));
2588        assert!(text.contains("model = \"provider/new\""));
2589        assert!(text.contains("[alias.cheap]"));
2590        assert!(text.contains("model = \"smart\""));
2591    }
2592
2593    #[test]
2594    fn bind_default_model_preserves_existing_cheap_alias() {
2595        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2596        let (_dir, hub) = temp_hub();
2597        hub.add_alias("smart", "provider/old").unwrap();
2598        hub.add_alias("cheap", "provider/custom-cheap").unwrap();
2599
2600        hub.bind_default_model("provider/new").unwrap();
2601
2602        let cfg = crate::model_registry::parse_config(&hub.read_config_toml().unwrap()).unwrap();
2603        assert_eq!(cfg.aliases["smart"].model, "provider/new");
2604        assert_eq!(cfg.aliases["cheap"].model, "provider/custom-cheap");
2605    }
2606
2607    #[test]
2608    fn bind_default_model_does_not_overwrite_smart_model() {
2609        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2610        let (_dir, hub) = temp_hub();
2611        hub.upsert_model(model(None, "smart", "provider/model"))
2612            .unwrap();
2613        let before = hub.read_config_toml().unwrap();
2614
2615        assert!(matches!(
2616            hub.bind_default_model("provider/new"),
2617            Err(ConfigError::NameConflict {
2618                domain: "models",
2619                ..
2620            })
2621        ));
2622        assert_eq!(hub.read_config_toml().unwrap(), before);
2623    }
2624
2625    #[test]
2626    fn distinct_names_may_use_the_same_provider_model_id() {
2627        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2628        let (_dir, hub) = temp_hub();
2629        hub.upsert_model(model(None, "first", "provider/shared"))
2630            .unwrap();
2631        hub.upsert_model(model(None, "second", "provider/shared"))
2632            .unwrap();
2633
2634        let text = hub.read_config_toml().unwrap();
2635        assert!(text.contains("[models.first]"));
2636        assert!(text.contains("[models.second]"));
2637    }
2638
2639    #[test]
2640    fn mcp_upsert_preserves_existing_json_servers_and_overrides_toml_by_name() {
2641        let (_dir, hub) = temp_hub();
2642        std::fs::write(
2643            hub.config_toml_path(),
2644            "[[mcp]]\nname = \"shared\"\ncommand = \"from-toml\"\n",
2645        )
2646        .unwrap();
2647        hub.save_mcp(&[crate::mcp::McpServerConfig::stdio(
2648            "existing",
2649            "existing-command",
2650            vec![],
2651            crate::tool::Tier::Two,
2652            30_000,
2653        )])
2654        .unwrap();
2655
2656        hub.upsert_mcp(crate::mcp::McpServerConfig::stdio(
2657            "shared",
2658            "from-json",
2659            vec![],
2660            crate::tool::Tier::Three,
2661            30_000,
2662        ))
2663        .unwrap();
2664
2665        let configs = hub.load_local_mcp();
2666        assert_eq!(configs.len(), 2);
2667        assert_eq!(
2668            configs
2669                .iter()
2670                .find(|cfg| cfg.name == "shared")
2671                .unwrap()
2672                .command,
2673            "from-json"
2674        );
2675        assert!(configs.iter().any(|cfg| cfg.name == "existing"));
2676        assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2677    }
2678
2679    #[test]
2680    fn mcp_toggle_toml_server_persists_json_override() {
2681        let (_dir, hub) = temp_hub();
2682        std::fs::write(
2683            hub.config_toml_path(),
2684            "[[mcp]]\nname = \"exa\"\ncommand = \"exa-mcp-server\"\n",
2685        )
2686        .unwrap();
2687
2688        assert!(hub.toggle_mcp("exa").unwrap());
2689
2690        let configs = hub.load_local_mcp();
2691        assert!(
2692            configs
2693                .iter()
2694                .find(|cfg| cfg.name == "exa")
2695                .unwrap()
2696                .disabled
2697        );
2698        assert!(hub.mcp_json_path().exists());
2699    }
2700
2701    #[test]
2702    fn mcp_remove_updates_json_atomically() {
2703        let (_dir, hub) = temp_hub();
2704        hub.save_mcp(&[
2705            crate::mcp::McpServerConfig::stdio(
2706                "first",
2707                "echo",
2708                vec![],
2709                crate::tool::Tier::Two,
2710                30_000,
2711            ),
2712            crate::mcp::McpServerConfig::stdio(
2713                "second",
2714                "ls",
2715                vec![],
2716                crate::tool::Tier::Two,
2717                30_000,
2718            ),
2719        ])
2720        .unwrap();
2721
2722        hub.remove_mcp("first").unwrap();
2723
2724        let configs = hub.load_local_mcp();
2725        assert_eq!(configs.len(), 1);
2726        assert_eq!(configs[0].name, "second");
2727        assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2728    }
2729
2730    #[test]
2731    fn alias_updates_preserve_comments_and_other_sections() {
2732        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2733        let (_dir, hub) = temp_hub();
2734        std::fs::write(
2735            hub.config_toml_path(),
2736            "# keep me\n[theme]\nname = \"dark\"\n\n[alias.old]\nmodel = \"provider/a\"\n",
2737        )
2738        .unwrap();
2739
2740        hub.update_alias(Some("old"), "new", "provider/b").unwrap();
2741
2742        let text = hub.read_config_toml().unwrap();
2743        assert!(text.contains("# keep me"));
2744        assert!(text.contains("[theme]"));
2745        assert!(text.contains("[alias.new]"));
2746        assert!(!text.contains("[alias.old]"));
2747    }
2748}