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, Copy, Default, PartialEq, Eq)]
63pub enum DiffLayout {
64    #[default]
65    Split,
66    Unified,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum InterjectionMode {
71    Off,
72    Rule,
73    Llm,
74    Unknown(String),
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
78pub struct RedactConfig {
79    pub enabled: bool,
80    pub partial: bool,
81    pub allowlist: Vec<String>,
82    pub custom_patterns: Vec<(String, String)>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct SandboxConfig {
87    pub enabled: bool,
88    pub strict: bool,
89    pub extra_read: Vec<PathBuf>,
90    pub extra_write: Vec<PathBuf>,
91    pub template_path: Option<PathBuf>,
92    pub allow_network: bool,
93}
94
95impl Default for SandboxConfig {
96    fn default() -> Self {
97        Self {
98            enabled: true,
99            strict: false,
100            extra_read: Vec::new(),
101            extra_write: Vec::new(),
102            template_path: None,
103            allow_network: false,
104        }
105    }
106}
107
108#[derive(Debug, Clone, Copy)]
109pub struct ProviderConfigUpdate<'a> {
110    pub name: &'a str,
111    pub kind: &'a str,
112    pub api_key: Option<&'a str>,
113    pub api_key_env: Option<&'a str>,
114    pub base_url: Option<&'a str>,
115    pub max_tokens: Option<u32>,
116    pub reasoning_format: Option<crate::providers::openai::OpenAiReasoningFormat>,
117    pub prompt_cache_key: Option<bool>,
118    pub enabled: bool,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub(crate) enum ProviderConfigWriteMode {
123    Upsert,
124    Create,
125    Update,
126}
127
128pub struct AuthTokenUpdate {
129    pub access_token: String,
130    pub refresh_token: Option<String>,
131    pub expires_at: i64,
132    pub account: Option<String>,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub(crate) enum AuthModelCacheCommit {
137    Updated,
138    Missing,
139    Disabled,
140    Changed,
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub(crate) enum AuthProviderInsertCommit {
145    Inserted,
146    Changed,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub(crate) enum AuthProviderRuntimeCommit {
151    Applied { auth_changed: bool },
152    Missing,
153    Disabled,
154    Changed,
155}
156
157#[derive(Debug, Clone)]
158pub(crate) struct AuthProviderRuntimeState {
159    pub provider: crate::auth_store::StoredProvider,
160    pub catalog_snapshot: crate::auth_store::AuthProviderCatalogSnapshot,
161    pub model_namespace: Option<String>,
162    pub model_cache: Option<Vec<crate::provider::DiscoveredModelDetails>>,
163    pub model_cache_freshness: crate::auth_store::ModelCacheFreshness,
164    pub provider_ids: Vec<String>,
165}
166
167pub(crate) struct AuthModelCacheUpdate<'a> {
168    pub expected: &'a crate::auth_store::StoredProvider,
169    pub expected_catalog: &'a crate::auth_store::AuthProviderCatalogSnapshot,
170    pub expected_provider_ids: Option<&'a [String]>,
171    pub model_namespace: &'a str,
172    pub fetched_at: i64,
173    pub models: &'a [crate::provider::DiscoveredModelDetails],
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub(crate) struct AuthProviderRuntimeDescriptor {
178    pub id: String,
179    pub kind: crate::auth_store::ProviderKind,
180    pub enabled: bool,
181}
182
183#[derive(Debug, Clone)]
184pub struct ConfigHub {
185    config_dir: PathBuf,
186    daemon_config_path: Option<PathBuf>,
187    auth_path: PathBuf,
188}
189
190impl ConfigHub {
191    pub fn global() -> Result<Self, ConfigError> {
192        let dir = crate::storage::config_dir()
193            .map_err(|error| ConfigError::Invalid(format!("config dir: {error}")))?;
194        Ok(Self::from_config_dir(dir))
195    }
196
197    pub fn from_config_dir(dir: impl Into<PathBuf>) -> Self {
198        let config_dir = dir.into();
199        let auth_path = config_dir.join("auth.json");
200        Self {
201            config_dir,
202            daemon_config_path: None,
203            auth_path,
204        }
205    }
206
207    pub fn config_dir(&self) -> &Path {
208        &self.config_dir
209    }
210
211    pub(crate) fn auth_path(&self) -> &Path {
212        &self.auth_path
213    }
214
215    pub fn config_toml_path(&self) -> PathBuf {
216        self.config_dir.join("config.toml")
217    }
218
219    pub fn validate_setting_mutation(&self, key: &str, value: &str) -> Result<(), ConfigError> {
220        crate::settings_catalog::validate_mutation(key, value)
221            .map_err(|error| ConfigError::Invalid(error.to_string()))
222    }
223
224    pub fn routes_at_path(&self) -> PathBuf {
225        self.config_dir.join("routes.at")
226    }
227
228    pub fn migrate_legacy_layout(
229        &self,
230        legacy_data_dir: &Path,
231    ) -> Result<Option<crate::config_migration::MigrationReport>, ConfigError> {
232        use fs2::FileExt;
233        let _guard = LAYOUT_MIGRATION_LOCK.lock().unwrap();
234        if legacy_data_dir == self.config_dir || !legacy_data_dir.exists() {
235            return Ok(None);
236        }
237        let lock = std::fs::OpenOptions::new()
238            .read(true)
239            .write(true)
240            .create(true)
241            .truncate(false)
242            .open(legacy_data_dir.join(".config-migration.lock"))?;
243        lock.lock_exclusive()?;
244        let _config_file_lock = lock_file(&self.config_dir.join(".config.toml.lock"))?;
245        let daemon_lock_path = self
246            .daemon_config_path
247            .as_deref()
248            .map(lock_path_for)
249            .unwrap_or_else(|| self.config_dir.join(".daemon.toml.lock"));
250        let _daemon_file_lock = lock_file(&daemon_lock_path)?;
251        let _routes_file_lock = lock_file(&self.config_dir.join(".routes.at.lock"))?;
252        crate::config_migration::relocate_legacy_layout(
253            &self.config_dir,
254            self.daemon_config_path.as_deref(),
255            legacy_data_dir,
256        )
257        .map_err(|error| ConfigError::Invalid(error.to_string()))
258    }
259
260    pub fn storage_config(&self, project_root: Option<&Path>) -> crate::storage::StorageConfig {
261        let global =
262            crate::storage::StorageConfig::load_from(&self.config_toml_path()).unwrap_or_default();
263        let project = project_root
264            .map(|root| {
265                crate::storage::StorageConfig::load_from(&root.join(".atman/config.toml"))
266                    .unwrap_or_default()
267            })
268            .unwrap_or_default();
269        crate::storage::StorageConfig::merge(global, project)
270    }
271
272    pub fn set_project_storage_scope(
273        &self,
274        project_root: &Path,
275        scope: crate::storage::StorageScope,
276    ) -> Result<(), ConfigError> {
277        let path = project_root.join(".atman/config.toml");
278        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
279        let _file_lock = lock_file(&lock_path_for(&path))?;
280        let text = match std::fs::read_to_string(&path) {
281            Ok(text) => text,
282            Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
283            Err(error) => return Err(error.into()),
284        };
285        let mut doc = if text.trim().is_empty() {
286            toml_edit::DocumentMut::new()
287        } else {
288            text.parse()?
289        };
290        if doc.get("storage").is_none() {
291            doc.insert("storage", toml_edit::Item::Table(toml_edit::Table::new()));
292        }
293        let storage = doc
294            .get_mut("storage")
295            .and_then(toml_edit::Item::as_table_mut)
296            .ok_or_else(|| ConfigError::Invalid("storage is not a table".into()))?;
297        let value = match scope {
298            crate::storage::StorageScope::Global => "global",
299            crate::storage::StorageScope::Local => "local",
300        };
301        storage.insert("scope", toml_edit::value(value));
302        write_unique_atomic(&path, doc.to_string().as_bytes())
303    }
304
305    pub fn load_routes_source(&self) -> Result<Option<String>, ConfigError> {
306        match std::fs::read_to_string(self.routes_at_path()) {
307            Ok(source) => Ok(Some(source)),
308            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
309            Err(error) => Err(ConfigError::Io(error)),
310        }
311    }
312
313    pub fn append_dsl_route(&self, flow_name: &str, trigger: &str) -> Result<(), ConfigError> {
314        use fs2::FileExt;
315
316        let route = dsl_route_source(flow_name, trigger)?;
317        let _guard = ROUTES_WRITE_LOCK.lock().unwrap();
318        std::fs::create_dir_all(&self.config_dir)?;
319        let lock = std::fs::OpenOptions::new()
320            .read(true)
321            .write(true)
322            .create(true)
323            .truncate(false)
324            .open(self.config_dir.join(".routes.at.lock"))?;
325        lock.lock_exclusive()?;
326
327        let path = self.routes_at_path();
328        let source = match std::fs::read_to_string(&path) {
329            Ok(source) => source,
330            Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
331            Err(error) => return Err(error.into()),
332        };
333        parse_routes_source("existing routes.at", &source)?;
334
335        let mut combined = source;
336        if !combined.is_empty() && !combined.ends_with('\n') {
337            combined.push('\n');
338        }
339        combined.push_str(&route);
340        parse_routes_source("updated routes.at", &combined)?;
341        write_unique_atomic(&path, combined.as_bytes())
342    }
343
344    pub fn mcp_json_path(&self) -> PathBuf {
345        self.config_dir.join("mcp_servers.json")
346    }
347
348    pub fn from_daemon_config_path(path: impl Into<PathBuf>) -> Self {
349        let path = path.into();
350        let config_dir = path
351            .parent()
352            .unwrap_or_else(|| Path::new("."))
353            .to_path_buf();
354        Self::from_config_dir(config_dir).with_daemon_config_path(path)
355    }
356
357    pub fn with_daemon_config_path(mut self, path: impl Into<PathBuf>) -> Self {
358        self.daemon_config_path = Some(path.into());
359        self
360    }
361
362    pub fn from_auth_path(path: impl Into<PathBuf>) -> Self {
363        let auth_path = path.into();
364        let config_dir = auth_path
365            .parent()
366            .unwrap_or_else(|| Path::new("."))
367            .to_path_buf();
368        Self {
369            config_dir,
370            daemon_config_path: None,
371            auth_path,
372        }
373    }
374
375    pub fn load_auth(&self) -> Result<crate::auth_store::AuthStore, ConfigError> {
376        load_auth_from_path(&self.auth_path)
377    }
378
379    pub fn load_auth_model_cache_details(
380        &self,
381        id: &str,
382    ) -> Result<Option<Vec<crate::provider::DiscoveredModelDetails>>, ConfigError> {
383        Ok(load_auth_document_from_path(&self.auth_path)?.model_cache_details(id))
384    }
385
386    #[cfg(test)]
387    pub(crate) fn load_or_create_auth_provider_catalog_state(
388        &self,
389        id: &str,
390    ) -> Result<
391        Option<(
392            crate::auth_store::StoredProvider,
393            crate::auth_store::AuthProviderCatalogSnapshot,
394        )>,
395        ConfigError,
396    > {
397        self.update_auth_document_conditionally(|document| {
398            let Some((state, changed)) = document.ensure_provider_catalog_state(id) else {
399                return Ok((None, false));
400            };
401            Ok((Some(state), changed))
402        })
403    }
404
405    pub(crate) fn load_or_create_auth_provider_runtime_state(
406        &self,
407        id: &str,
408    ) -> Result<Option<AuthProviderRuntimeState>, ConfigError> {
409        self.load_or_create_auth_provider_runtime_state_at(id, chrono::Utc::now().timestamp())
410    }
411
412    pub(crate) fn load_or_create_auth_provider_runtime_state_at(
413        &self,
414        id: &str,
415        now: i64,
416    ) -> Result<Option<AuthProviderRuntimeState>, ConfigError> {
417        self.update_auth_document_conditionally(|document| {
418            let Some(((provider, catalog_snapshot), changed)) =
419                document.ensure_provider_catalog_state(id)
420            else {
421                return Ok((None, false));
422            };
423            let model_namespace = document.model_namespace(id);
424            let model_cache = document.model_cache_details(id);
425            let model_cache_freshness = document
426                .model_cache_freshness(
427                    id,
428                    now,
429                    crate::auth_store::MODEL_CACHE_FRESHNESS_WINDOW_SECONDS,
430                )
431                .ok_or_else(|| {
432                    ConfigError::Invalid(format!(
433                        "auth provider `{id}` disappeared while reading its model cache"
434                    ))
435                })?;
436            let provider_ids = sorted_auth_provider_ids(&document.legacy_view());
437            Ok((
438                Some(AuthProviderRuntimeState {
439                    provider,
440                    catalog_snapshot,
441                    model_namespace,
442                    model_cache,
443                    model_cache_freshness,
444                    provider_ids,
445                }),
446                changed,
447            ))
448        })
449    }
450
451    pub(crate) fn load_or_create_auth_provider_credential_state(
452        &self,
453        id: &str,
454    ) -> Result<
455        Option<(
456            crate::auth_store::StoredProvider,
457            crate::auth_store::AuthProviderCredentialSnapshot,
458        )>,
459        ConfigError,
460    > {
461        match load_auth_document_from_path(&self.auth_path)?.provider_credential_state(id) {
462            None => return Ok(None),
463            Some((provider, Some(snapshot))) => return Ok(Some((provider, snapshot))),
464            Some((_provider, None)) => {}
465        }
466        self.update_auth_document_conditionally(|document| {
467            let Some((state, changed)) = document.ensure_provider_credential_state(id) else {
468                return Ok((None, false));
469            };
470            Ok((Some(state), changed))
471        })
472    }
473
474    pub fn load_auth_model_namespace(&self, id: &str) -> Result<Option<String>, ConfigError> {
475        Ok(load_auth_document_from_path(&self.auth_path)?.model_namespace(id))
476    }
477
478    pub fn ensure_auth_model_namespace(
479        &self,
480        id: &str,
481        model_namespace: &str,
482    ) -> Result<(), ConfigError> {
483        if let Some(existing) = self.load_auth_model_namespace(id)? {
484            if existing == model_namespace {
485                return Ok(());
486            }
487            return Err(ConfigError::Invalid(format!(
488                "provider `{id}` model namespace is already `{existing}`"
489            )));
490        }
491        self.update_auth_document(|document| {
492            document
493                .ensure_model_namespace(id, model_namespace)
494                .map(|_| ())
495                .map_err(ConfigError::Invalid)
496        })
497    }
498
499    pub fn update_auth<T>(
500        &self,
501        mutate: impl FnOnce(&mut crate::auth_store::AuthStore) -> Result<T, ConfigError>,
502    ) -> Result<T, ConfigError> {
503        self.update_auth_document(|document| {
504            let mut store = document.legacy_view();
505            let result = mutate(&mut store)?;
506            document.merge_legacy_view(store);
507            Ok(result)
508        })
509    }
510
511    fn update_auth_document<T>(
512        &self,
513        mutate: impl FnOnce(&mut crate::auth_store::AuthStoreDocument) -> Result<T, ConfigError>,
514    ) -> Result<T, ConfigError> {
515        self.update_auth_document_conditionally(|document| {
516            mutate(document).map(|result| (result, true))
517        })
518    }
519
520    fn update_auth_document_conditionally<T>(
521        &self,
522        mutate: impl FnOnce(&mut crate::auth_store::AuthStoreDocument) -> Result<(T, bool), ConfigError>,
523    ) -> Result<T, ConfigError> {
524        self.update_auth_document_conditionally_and_then(mutate, |_| ())
525            .map(|(result, ())| result)
526    }
527
528    fn update_auth_document_conditionally_and_then<T, U>(
529        &self,
530        mutate: impl FnOnce(&mut crate::auth_store::AuthStoreDocument) -> Result<(T, bool), ConfigError>,
531        after_write: impl FnOnce(&T) -> U,
532    ) -> Result<(T, U), ConfigError> {
533        use fs2::FileExt;
534
535        let _guard = AUTH_WRITE_LOCK
536            .lock()
537            .unwrap_or_else(std::sync::PoisonError::into_inner);
538        let parent = self.auth_path.parent().unwrap_or_else(|| Path::new("."));
539        std::fs::create_dir_all(parent)?;
540        let lock_path = parent.join(".auth.json.lock");
541        let lock = std::fs::OpenOptions::new()
542            .read(true)
543            .write(true)
544            .create(true)
545            .truncate(false)
546            .open(lock_path)?;
547        set_sensitive_file_permissions(
548            &self
549                .auth_path
550                .parent()
551                .unwrap_or_else(|| Path::new("."))
552                .join(".auth.json.lock"),
553        )?;
554        lock.lock_exclusive()?;
555        let mut document = load_auth_document_from_path(&self.auth_path)?;
556        let (result, changed) = mutate(&mut document)?;
557        if changed {
558            self.write_auth_document(&document)?;
559        }
560        let follow_up = after_write(&result);
561        Ok((result, follow_up))
562    }
563
564    pub fn add_auth_provider(
565        &self,
566        provider: crate::auth_store::StoredProvider,
567    ) -> Result<(), ConfigError> {
568        self.update_auth(|store| {
569            if store
570                .providers
571                .iter()
572                .any(|existing| existing.id == provider.id)
573            {
574                return Err(ConfigError::Invalid(format!(
575                    "auth provider id {:?} already exists",
576                    provider.id
577                )));
578            }
579            store.providers.push(provider);
580            Ok(())
581        })
582    }
583
584    #[cfg(test)]
585    pub(crate) fn add_auth_provider_with_model_cache_details(
586        &self,
587        provider: crate::auth_store::StoredProvider,
588        model_namespace: &str,
589        fetched_at: i64,
590        models: &[crate::provider::DiscoveredModelDetails],
591    ) -> Result<(), ConfigError> {
592        let (commit, result) = self
593            .add_auth_provider_with_model_cache_details_if_provider_ids_and_then(
594                provider,
595                None,
596                model_namespace,
597                fetched_at,
598                models,
599                || (),
600            )?;
601        match (commit, result) {
602            (AuthProviderInsertCommit::Inserted, Some(())) => Ok(()),
603            (AuthProviderInsertCommit::Changed, _) => {
604                unreachable!("provider ID snapshot was not supplied")
605            }
606            (AuthProviderInsertCommit::Inserted, None) => {
607                unreachable!("provider insertion callback was not run")
608            }
609        }
610    }
611
612    pub(crate) fn add_auth_provider_with_model_cache_details_if_provider_ids_and_then<T>(
613        &self,
614        provider: crate::auth_store::StoredProvider,
615        expected_provider_ids: Option<&[String]>,
616        model_namespace: &str,
617        fetched_at: i64,
618        models: &[crate::provider::DiscoveredModelDetails],
619        after_write: impl FnOnce() -> T,
620    ) -> Result<(AuthProviderInsertCommit, Option<T>), ConfigError> {
621        self.update_auth_document_conditionally_and_then(
622            |document| {
623                let provider_id = provider.id.clone();
624                let mut store = document.legacy_view();
625                if expected_provider_ids
626                    .is_some_and(|expected| sorted_auth_provider_ids(&store) != expected)
627                {
628                    return Ok((AuthProviderInsertCommit::Changed, false));
629                }
630                if store
631                    .providers
632                    .iter()
633                    .any(|existing| existing.id == provider_id)
634                {
635                    return Err(ConfigError::Invalid(format!(
636                        "auth provider id {provider_id:?} already exists"
637                    )));
638                }
639                store.providers.push(provider);
640                document.merge_legacy_view(store);
641                let updated = document
642                    .update_model_cache_details(&provider_id, model_namespace, fetched_at, models)
643                    .map_err(ConfigError::Invalid)?;
644                if !updated {
645                    return Err(ConfigError::Invalid(format!(
646                        "auth provider `{provider_id}` disappeared during insertion"
647                    )));
648                }
649                Ok((AuthProviderInsertCommit::Inserted, true))
650            },
651            |commit| (*commit == AuthProviderInsertCommit::Inserted).then(after_write),
652        )
653    }
654
655    pub fn remove_auth_provider(&self, id: &str) -> Result<bool, ConfigError> {
656        self.update_auth(|store| Ok(store.remove(id)))
657    }
658
659    pub fn set_auth_provider_enabled(&self, id: &str, enabled: bool) -> Result<bool, ConfigError> {
660        Ok(self
661            .set_auth_provider_enabled_with_change(id, enabled)?
662            .is_some())
663    }
664
665    pub(crate) fn set_auth_provider_enabled_with_change(
666        &self,
667        id: &str,
668        enabled: bool,
669    ) -> Result<Option<bool>, ConfigError> {
670        self.update_auth_document_conditionally(|document| {
671            let Some(changed) = document.set_provider_enabled(id, enabled) else {
672                return Ok((None, false));
673            };
674            Ok((Some(changed), changed))
675        })
676    }
677
678    pub fn update_auth_tokens(
679        &self,
680        id: &str,
681        update: AuthTokenUpdate,
682    ) -> Result<bool, ConfigError> {
683        self.update_auth(|store| {
684            let Some(provider) = store
685                .providers
686                .iter_mut()
687                .find(|provider| provider.id == id)
688            else {
689                return Ok(false);
690            };
691            provider.access_token = update.access_token;
692            provider.expires_at = update.expires_at;
693            if update.refresh_token.is_some() {
694                provider.refresh_token = update.refresh_token;
695            }
696            if update.account.is_some() {
697                provider.account = update.account;
698            }
699            Ok(true)
700        })
701    }
702
703    pub(crate) fn update_auth_tokens_if_current(
704        &self,
705        id: &str,
706        expected: &crate::auth_store::AuthProviderCredentialSnapshot,
707        update: AuthTokenUpdate,
708    ) -> Result<crate::auth_store::AuthCredentialCommit, ConfigError> {
709        self.update_auth_document_conditionally(|document| {
710            let commit = document.update_provider_credentials(
711                id,
712                expected,
713                update.access_token,
714                update.refresh_token,
715                update.expires_at,
716                update.account,
717            );
718            let changed = matches!(
719                commit,
720                crate::auth_store::AuthCredentialCommit::Updated { .. }
721            );
722            Ok((commit, changed))
723        })
724    }
725
726    pub fn update_auth_model_cache(
727        &self,
728        id: &str,
729        cache: crate::auth_store::ModelCache,
730    ) -> Result<bool, ConfigError> {
731        self.update_auth_document_conditionally(|document| {
732            let updated = document.update_model_cache(id, cache);
733            Ok((updated, updated))
734        })
735    }
736
737    pub(crate) fn update_auth_model_cache_details(
738        &self,
739        id: &str,
740        model_namespace: &str,
741        fetched_at: i64,
742        models: &[crate::provider::DiscoveredModelDetails],
743    ) -> Result<bool, ConfigError> {
744        self.update_auth_document(|document| {
745            document
746                .update_model_cache_details(id, model_namespace, fetched_at, models)
747                .map_err(ConfigError::Invalid)
748        })
749    }
750
751    #[cfg(test)]
752    pub(crate) fn update_auth_model_cache_details_if_enabled(
753        &self,
754        expected: &crate::auth_store::StoredProvider,
755        expected_catalog: &crate::auth_store::AuthProviderCatalogSnapshot,
756        model_namespace: &str,
757        fetched_at: i64,
758        models: &[crate::provider::DiscoveredModelDetails],
759    ) -> Result<AuthModelCacheCommit, ConfigError> {
760        self.update_auth_model_cache_details_if_enabled_and_then(
761            AuthModelCacheUpdate {
762                expected,
763                expected_catalog,
764                expected_provider_ids: None,
765                model_namespace,
766                fetched_at,
767                models,
768            },
769            || (),
770        )
771        .map(|(commit, _)| commit)
772    }
773
774    pub(crate) fn update_auth_model_cache_details_if_enabled_and_then<T>(
775        &self,
776        update: AuthModelCacheUpdate<'_>,
777        after_update: impl FnOnce() -> T,
778    ) -> Result<(AuthModelCacheCommit, Option<T>), ConfigError> {
779        let AuthModelCacheUpdate {
780            expected,
781            expected_catalog,
782            expected_provider_ids,
783            model_namespace,
784            fetched_at,
785            models,
786        } = update;
787        self.update_auth_document_conditionally_and_then(
788            |document| {
789                let store = document.legacy_view();
790                let Some(provider) = store
791                    .providers
792                    .iter()
793                    .find(|provider| provider.id == expected.id)
794                else {
795                    return Ok((AuthModelCacheCommit::Missing, false));
796                };
797                if !provider.enabled {
798                    return Ok((AuthModelCacheCommit::Disabled, false));
799                }
800                if !auth_provider_matches(provider, expected)
801                    || document.provider_catalog_snapshot(&expected.id).as_ref()
802                        != Some(expected_catalog)
803                {
804                    return Ok((AuthModelCacheCommit::Changed, false));
805                }
806                if expected_provider_ids
807                    .is_some_and(|expected| sorted_auth_provider_ids(&store) != expected)
808                {
809                    return Ok((AuthModelCacheCommit::Changed, false));
810                }
811                let updated = document
812                    .update_model_cache_details(&expected.id, model_namespace, fetched_at, models)
813                    .map_err(ConfigError::Invalid)?;
814                if !updated {
815                    return Ok((AuthModelCacheCommit::Missing, false));
816                }
817                Ok((AuthModelCacheCommit::Updated, true))
818            },
819            |commit| (*commit == AuthModelCacheCommit::Updated).then(after_update),
820        )
821    }
822
823    pub(crate) fn commit_auth_provider_runtime_if_current_and_then<T>(
824        &self,
825        expected: &crate::auth_store::StoredProvider,
826        expected_catalog: &crate::auth_store::AuthProviderCatalogSnapshot,
827        enable_if_disabled: bool,
828        model_namespace: Option<&str>,
829        expected_provider_ids: Option<&[String]>,
830        after_commit: impl FnOnce() -> T,
831    ) -> Result<(AuthProviderRuntimeCommit, Option<T>), ConfigError> {
832        self.update_auth_document_conditionally_and_then(
833            |document| {
834                let store = document.legacy_view();
835                let Some(provider) = store
836                    .providers
837                    .iter()
838                    .find(|provider| provider.id == expected.id)
839                else {
840                    return Ok((AuthProviderRuntimeCommit::Missing, false));
841                };
842                if !auth_provider_matches(provider, expected)
843                    || document.provider_catalog_snapshot(&expected.id).as_ref()
844                        != Some(expected_catalog)
845                {
846                    return Ok((AuthProviderRuntimeCommit::Changed, false));
847                }
848                if !provider.enabled && !enable_if_disabled {
849                    return Ok((AuthProviderRuntimeCommit::Disabled, false));
850                }
851                if let Some(expected_provider_ids) = expected_provider_ids {
852                    let provider_ids = sorted_auth_provider_ids(&store);
853                    if provider_ids != expected_provider_ids {
854                        return Ok((AuthProviderRuntimeCommit::Changed, false));
855                    }
856                }
857
858                let enabled_changed = if enable_if_disabled {
859                    document
860                        .set_provider_enabled(&expected.id, true)
861                        .ok_or_else(|| {
862                            ConfigError::Invalid(format!(
863                                "auth provider `{}` disappeared during activation",
864                                expected.id
865                            ))
866                        })?
867                } else {
868                    false
869                };
870                let namespace_changed = match model_namespace {
871                    Some(namespace) => document
872                        .ensure_model_namespace(&expected.id, namespace)
873                        .map_err(ConfigError::Invalid)?,
874                    None => false,
875                };
876                Ok((
877                    AuthProviderRuntimeCommit::Applied {
878                        auth_changed: enabled_changed || namespace_changed,
879                    },
880                    enabled_changed || namespace_changed,
881                ))
882            },
883            |commit| matches!(commit, AuthProviderRuntimeCommit::Applied { .. }).then(after_commit),
884        )
885    }
886
887    pub(crate) fn with_auth_provider_runtime_descriptors<T>(
888        &self,
889        inspect: impl FnOnce(&[AuthProviderRuntimeDescriptor]) -> T,
890    ) -> Result<T, ConfigError> {
891        self.update_auth_document_conditionally_and_then(
892            |document| {
893                let providers = document
894                    .legacy_view()
895                    .providers
896                    .into_iter()
897                    .map(|provider| AuthProviderRuntimeDescriptor {
898                        id: provider.id,
899                        kind: provider.kind,
900                        enabled: provider.enabled,
901                    })
902                    .collect::<Vec<_>>();
903                Ok((providers, false))
904            },
905            |providers| inspect(providers),
906        )
907        .map(|(_, result)| result)
908    }
909
910    pub fn load_or_init_daemon_config(&self) -> Result<DaemonConfig, ConfigError> {
911        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
912        let path = self
913            .daemon_config_path
914            .as_deref()
915            .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
916        let _file_lock = lock_file(&lock_path_for(path))?;
917        match std::fs::read_to_string(path) {
918            Ok(text) => toml::from_str(&text).map_err(|error| {
919                ConfigError::Invalid(format!("parse {}: {error}", path.display()))
920            }),
921            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
922                let config = DaemonConfig {
923                    auth_token: generate_daemon_token(),
924                };
925                self.write_daemon_config(&config)?;
926                Ok(config)
927            }
928            Err(error) => Err(error.into()),
929        }
930    }
931
932    pub fn rotate_daemon_config(&self) -> Result<DaemonConfig, ConfigError> {
933        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
934        let path = self
935            .daemon_config_path
936            .as_deref()
937            .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
938        let _file_lock = lock_file(&lock_path_for(path))?;
939        if !path.exists() {
940            return Err(ConfigError::Invalid(format!(
941                "no daemon config at {} — nothing to rotate. Run `atman daemon start` once to generate one.",
942                path.display()
943            )));
944        }
945        let config = DaemonConfig {
946            auth_token: generate_daemon_token(),
947        };
948        self.write_daemon_config(&config)?;
949        Ok(config)
950    }
951
952    pub fn read_config_toml(&self) -> Result<String, ConfigError> {
953        match std::fs::read_to_string(self.config_toml_path()) {
954            Ok(text) => Ok(text),
955            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
956            Err(error) => Err(error.into()),
957        }
958    }
959
960    pub fn theme_preference(&self) -> Result<ThemePreference, ConfigError> {
961        let text = self.read_config_toml()?;
962        if text.trim().is_empty() {
963            return Ok(ThemePreference::Auto);
964        }
965        let document = text.parse::<toml_edit::DocumentMut>()?;
966        let Some(theme) = document.get("theme") else {
967            return Ok(ThemePreference::Auto);
968        };
969        let Some(theme) = theme.as_table() else {
970            return Err(ConfigError::Invalid("theme is not a table".into()));
971        };
972        let Some(mode) = theme.get("mode") else {
973            return Ok(ThemePreference::Auto);
974        };
975        let Some(mode) = mode.as_str() else {
976            return Err(ConfigError::Invalid("theme.mode is not a string".into()));
977        };
978        match mode.to_ascii_lowercase().as_str() {
979            "auto" => Ok(ThemePreference::Auto),
980            "light" => Ok(ThemePreference::Light),
981            "dark" => Ok(ThemePreference::Dark),
982            _ => Err(ConfigError::Invalid(format!(
983                "invalid theme.mode: {mode:?}"
984            ))),
985        }
986    }
987
988    pub fn diff_layout(&self) -> Result<DiffLayout, ConfigError> {
989        let text = self.read_config_toml()?;
990        if text.trim().is_empty() {
991            return Ok(DiffLayout::Split);
992        }
993        let document = text.parse::<toml_edit::DocumentMut>()?;
994        let Some(diff) = document.get("diff") else {
995            return Ok(DiffLayout::Split);
996        };
997        let Some(diff) = diff.as_table() else {
998            return Err(ConfigError::Invalid("diff is not a table".into()));
999        };
1000        let Some(layout) = diff.get("layout") else {
1001            return Ok(DiffLayout::Split);
1002        };
1003        let Some(layout) = layout.as_str() else {
1004            return Err(ConfigError::Invalid("diff.layout is not a string".into()));
1005        };
1006        match layout.to_ascii_lowercase().as_str() {
1007            "split" => Ok(DiffLayout::Split),
1008            "unified" => Ok(DiffLayout::Unified),
1009            other => Err(ConfigError::Invalid(format!(
1010                "diff.layout must be `split` or `unified`, got `{other}`"
1011            ))),
1012        }
1013    }
1014
1015    pub fn fs_access_mode(&self) -> Result<Option<crate::fs_access::FsAccessMode>, ConfigError> {
1016        let text = self.read_config_toml()?;
1017        if text.trim().is_empty() {
1018            return Ok(None);
1019        }
1020        let document = text.parse::<toml_edit::DocumentMut>()?;
1021        let Some(fs_access) = document.get("fs_access") else {
1022            return Ok(None);
1023        };
1024        let Some(fs_access) = fs_access.as_table() else {
1025            return Err(ConfigError::Invalid("fs_access is not a table".into()));
1026        };
1027        let Some(mode) = fs_access.get("mode") else {
1028            return Ok(None);
1029        };
1030        let Some(mode) = mode.as_str() else {
1031            return Err(ConfigError::Invalid(
1032                "fs_access.mode is not a string".into(),
1033            ));
1034        };
1035        crate::fs_access::FsAccessMode::from_str(mode)
1036            .map(Some)
1037            .map_err(ConfigError::Invalid)
1038    }
1039
1040    pub fn auto_snapshot(&self) -> Result<Option<bool>, ConfigError> {
1041        let text = self.read_config_toml()?;
1042        if text.trim().is_empty() {
1043            return Ok(None);
1044        }
1045        let document = text.parse::<toml_edit::DocumentMut>()?;
1046        let Some(registry) = document.get("registry") else {
1047            return Ok(None);
1048        };
1049        let Some(registry) = registry.as_table() else {
1050            return Err(ConfigError::Invalid("registry is not a table".into()));
1051        };
1052        let Some(auto_snapshot) = registry.get("auto_snapshot") else {
1053            return Ok(None);
1054        };
1055        if let Some(value) = auto_snapshot.as_bool() {
1056            return Ok(Some(value));
1057        }
1058        if let Some(value) = auto_snapshot.as_integer() {
1059            return Ok(Some(value == 1));
1060        }
1061        if let Some(value) = auto_snapshot.as_str() {
1062            return Ok(Some(value == "true"));
1063        }
1064        Err(ConfigError::Invalid(
1065            "registry.auto_snapshot has an unsupported type".into(),
1066        ))
1067    }
1068
1069    pub fn compact_review_mode(&self) -> Result<Option<crate::CompactReviewMode>, ConfigError> {
1070        let text = self.read_config_toml()?;
1071        if text.trim().is_empty() {
1072            return Ok(None);
1073        }
1074        let document = text.parse::<toml_edit::DocumentMut>()?;
1075        let Some(compaction) = document.get("compaction") else {
1076            return Ok(None);
1077        };
1078        let Some(compaction) = compaction.as_table() else {
1079            return Err(ConfigError::Invalid("compaction is not a table".into()));
1080        };
1081        let Some(review) = compaction.get("review") else {
1082            return Ok(None);
1083        };
1084        let Some(review) = review.as_str() else {
1085            return Err(ConfigError::Invalid(
1086                "compaction.review is not a string".into(),
1087            ));
1088        };
1089        crate::CompactReviewMode::parse(review)
1090            .map(Some)
1091            .ok_or_else(|| ConfigError::Invalid(format!("invalid compaction.review: {review:?}")))
1092    }
1093
1094    pub fn suggest_model(&self) -> Result<Option<String>, ConfigError> {
1095        let text = self.read_config_toml()?;
1096        if text.trim().is_empty() {
1097            return Ok(None);
1098        }
1099        let document = text.parse::<toml_edit::DocumentMut>()?;
1100        let Some(suggest) = document.get("suggest") else {
1101            return Ok(None);
1102        };
1103        let Some(suggest) = suggest.as_table() else {
1104            return Err(ConfigError::Invalid("suggest is not a table".into()));
1105        };
1106        let Some(model) = suggest.get("model") else {
1107            return Ok(None);
1108        };
1109        let Some(model) = model.as_str() else {
1110            return Err(ConfigError::Invalid("suggest.model is not a string".into()));
1111        };
1112        Ok(Some(model.to_string()))
1113    }
1114
1115    pub fn interjection_mode(&self) -> Result<Option<InterjectionMode>, ConfigError> {
1116        let text = self.read_config_toml()?;
1117        if text.trim().is_empty() {
1118            return Ok(None);
1119        }
1120        let document = text.parse::<toml_edit::DocumentMut>()?;
1121        let Some(interjection) = document.get("interjection") else {
1122            return Ok(None);
1123        };
1124        let Some(interjection) = interjection.as_table() else {
1125            return Err(ConfigError::Invalid("interjection is not a table".into()));
1126        };
1127        let Some(classifier) = interjection.get("classifier") else {
1128            return Ok(None);
1129        };
1130        let Some(classifier) = classifier.as_str() else {
1131            return Err(ConfigError::Invalid(
1132                "interjection.classifier is not a string".into(),
1133            ));
1134        };
1135        Ok(Some(match classifier {
1136            "off" => InterjectionMode::Off,
1137            "rule" => InterjectionMode::Rule,
1138            "llm" => InterjectionMode::Llm,
1139            other => InterjectionMode::Unknown(other.to_string()),
1140        }))
1141    }
1142
1143    pub fn tool_output_budget(
1144        &self,
1145    ) -> Result<crate::tools::tool_output::ToolOutputBudget, ConfigError> {
1146        #[derive(Debug, serde::Deserialize, Default)]
1147        struct RawToolOutput {
1148            #[serde(default)]
1149            max_lines: Option<usize>,
1150            #[serde(default)]
1151            max_bytes: Option<usize>,
1152            #[serde(default)]
1153            max_line_bytes: Option<usize>,
1154        }
1155        #[derive(Debug, serde::Deserialize, Default)]
1156        struct RawFile {
1157            #[serde(default)]
1158            tool_output: RawToolOutput,
1159        }
1160        let text = self.read_config_toml()?;
1161        if text.trim().is_empty() {
1162            return Ok(Default::default());
1163        }
1164        let raw: RawFile = toml::from_str(&text)
1165            .map_err(|error| ConfigError::Invalid(format!("tool_output config: {error}")))?;
1166        let defaults = crate::tools::tool_output::ToolOutputBudget::default();
1167        let budget = crate::tools::tool_output::ToolOutputBudget {
1168            max_lines: raw.tool_output.max_lines.unwrap_or(defaults.max_lines),
1169            max_bytes: raw.tool_output.max_bytes.unwrap_or(defaults.max_bytes),
1170            max_line_bytes: raw
1171                .tool_output
1172                .max_line_bytes
1173                .unwrap_or(defaults.max_line_bytes),
1174        };
1175        if budget.max_lines == 0 || budget.max_bytes == 0 || budget.max_line_bytes == 0 {
1176            return Err(ConfigError::Invalid(
1177                "tool_output budgets must be positive".into(),
1178            ));
1179        }
1180        Ok(budget)
1181    }
1182
1183    pub fn web_fetch_config(&self) -> Result<crate::tools::web::WebConfig, ConfigError> {
1184        #[derive(Debug, serde::Deserialize, Default)]
1185        struct RawWeb {
1186            #[serde(default)]
1187            max_bytes: Option<usize>,
1188            #[serde(default)]
1189            url_allowlist: Vec<String>,
1190            #[serde(default)]
1191            url_denylist: Vec<String>,
1192        }
1193        #[derive(Debug, serde::Deserialize, Default)]
1194        struct RawWebFile {
1195            #[serde(default)]
1196            web: RawWeb,
1197        }
1198
1199        let text = self.read_config_toml()?;
1200        let mut config = crate::tools::web::WebConfig::default();
1201        if text.trim().is_empty() {
1202            return Ok(config);
1203        }
1204        let file: RawWebFile = toml::from_str(&text)
1205            .map_err(|error| ConfigError::Invalid(format!("parse web fetch config: {error}")))?;
1206        if let Some(value) = file.web.max_bytes {
1207            config.max_bytes = value;
1208        }
1209        if !file.web.url_allowlist.is_empty() {
1210            config.url_allowlist = file.web.url_allowlist;
1211        }
1212        if !file.web.url_denylist.is_empty() {
1213            config.url_denylist = file.web.url_denylist;
1214        }
1215        Ok(config)
1216    }
1217
1218    pub fn web_search_config(&self) -> Result<crate::tools::web::SearchConfig, ConfigError> {
1219        #[derive(Debug, serde::Deserialize, Default)]
1220        struct RawWeb {
1221            #[serde(default)]
1222            search: Option<crate::tools::web::SearchConfig>,
1223        }
1224        #[derive(Debug, serde::Deserialize, Default)]
1225        struct RawWebFile {
1226            #[serde(default)]
1227            web: RawWeb,
1228        }
1229
1230        let text = self.read_config_toml()?;
1231        if text.trim().is_empty() {
1232            return Ok(crate::tools::web::SearchConfig::default());
1233        }
1234        let file: RawWebFile = toml::from_str(&text)
1235            .map_err(|error| ConfigError::Invalid(format!("parse web search config: {error}")))?;
1236        Ok(file.web.search.unwrap_or_default())
1237    }
1238
1239    pub fn trust_config(&self) -> Result<crate::trust::TrustConfig, ConfigError> {
1240        #[derive(Debug, serde::Deserialize, Default)]
1241        struct RawTrustFile {
1242            #[serde(default)]
1243            trust: crate::trust::TrustConfig,
1244        }
1245
1246        let text = self.read_config_toml()?;
1247        if text.trim().is_empty() {
1248            return Ok(crate::trust::TrustConfig::default());
1249        }
1250        let file: RawTrustFile = toml::from_str(&text)
1251            .map_err(|error| ConfigError::Invalid(format!("parse trust config: {error}")))?;
1252        Ok(file.trust)
1253    }
1254
1255    pub fn set_trust_config(&self, trust: &crate::trust::TrustConfig) -> Result<(), ConfigError> {
1256        let serialized = toml::to_string(trust)
1257            .map_err(|error| ConfigError::Invalid(format!("serialize trust config: {error}")))?;
1258        let trust_doc = serialized.parse::<toml_edit::DocumentMut>()?;
1259        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1260        let _file_lock = self.lock_config_file()?;
1261        let text = self.read_config_toml()?;
1262        let mut doc = if text.trim().is_empty() {
1263            toml_edit::DocumentMut::new()
1264        } else {
1265            text.parse()?
1266        };
1267        let mut table = toml_edit::Table::new();
1268        for (key, item) in trust_doc.iter() {
1269            table.insert(key, item.clone());
1270        }
1271        doc.insert("trust", toml_edit::Item::Table(table));
1272        self.write_config_toml(&doc.to_string())
1273    }
1274
1275    pub fn preview_config(&self) -> Result<crate::tools::preview::PreviewConfig, ConfigError> {
1276        #[derive(Debug, serde::Deserialize, Default)]
1277        struct RawPreview {
1278            #[serde(default)]
1279            base_url: Option<String>,
1280            #[serde(default)]
1281            timeout_ms: Option<u64>,
1282            #[serde(default)]
1283            project_abs_path: Option<String>,
1284            #[serde(default)]
1285            project_hint_slug: Option<String>,
1286            #[serde(default)]
1287            max_body_bytes: Option<usize>,
1288        }
1289        #[derive(Debug, serde::Deserialize, Default)]
1290        struct RawPreviewFile {
1291            #[serde(default)]
1292            preview: RawPreview,
1293        }
1294
1295        let text = self.read_config_toml()?;
1296        let mut config = crate::tools::preview::PreviewConfig::default();
1297        if text.trim().is_empty() {
1298            return Ok(config);
1299        }
1300        let file: RawPreviewFile = toml::from_str(&text)
1301            .map_err(|error| ConfigError::Invalid(format!("parse preview config: {error}")))?;
1302        if let Some(value) = file.preview.base_url {
1303            config.base_url = value;
1304        }
1305        if let Some(value) = file.preview.timeout_ms {
1306            config.timeout_ms = value;
1307        }
1308        if let Some(value) = file.preview.project_abs_path {
1309            config.project_abs_path = value;
1310        }
1311        if let Some(value) = file.preview.project_hint_slug {
1312            config.project_hint_slug = Some(value);
1313        }
1314        if let Some(value) = file.preview.max_body_bytes {
1315            config.max_body_bytes = value;
1316        }
1317        Ok(config)
1318    }
1319
1320    pub fn sandbox_config(&self) -> Result<SandboxConfig, ConfigError> {
1321        #[derive(Debug, serde::Deserialize, Default)]
1322        struct RawSandbox {
1323            #[serde(default)]
1324            enabled: Option<bool>,
1325            #[serde(default)]
1326            strict: bool,
1327            #[serde(default)]
1328            extra_read: Vec<String>,
1329            #[serde(default)]
1330            extra_write: Vec<String>,
1331            #[serde(default)]
1332            template_path: Option<String>,
1333            #[serde(default)]
1334            allow_network: Option<bool>,
1335        }
1336        #[derive(Debug, serde::Deserialize, Default)]
1337        struct RawSandboxFile {
1338            #[serde(default)]
1339            sandbox: RawSandbox,
1340        }
1341
1342        let text = self.read_config_toml()?;
1343        if text.trim().is_empty() {
1344            return Ok(SandboxConfig::default());
1345        }
1346        let file: RawSandboxFile = toml::from_str(&text)
1347            .map_err(|error| ConfigError::Invalid(format!("parse sandbox config: {error}")))?;
1348        Ok(SandboxConfig {
1349            enabled: file.sandbox.enabled.unwrap_or(true),
1350            strict: file.sandbox.strict,
1351            extra_read: file
1352                .sandbox
1353                .extra_read
1354                .into_iter()
1355                .map(PathBuf::from)
1356                .collect(),
1357            extra_write: file
1358                .sandbox
1359                .extra_write
1360                .into_iter()
1361                .map(PathBuf::from)
1362                .collect(),
1363            template_path: file.sandbox.template_path.map(PathBuf::from),
1364            allow_network: file.sandbox.allow_network.unwrap_or(false),
1365        })
1366    }
1367
1368    pub fn redact_config(&self) -> Result<RedactConfig, ConfigError> {
1369        #[derive(Debug, serde::Deserialize, Default)]
1370        struct RawPattern {
1371            kind: String,
1372            regex: String,
1373        }
1374        #[derive(Debug, serde::Deserialize, Default)]
1375        struct RawRedact {
1376            #[serde(default)]
1377            enabled: bool,
1378            #[serde(default)]
1379            mode: Option<String>,
1380            #[serde(default)]
1381            allowlist: Vec<String>,
1382            #[serde(default)]
1383            custom_patterns: Vec<RawPattern>,
1384        }
1385        #[derive(Debug, serde::Deserialize, Default)]
1386        struct RawRedactFile {
1387            #[serde(default)]
1388            redact: RawRedact,
1389        }
1390
1391        let text = self.read_config_toml()?;
1392        if text.trim().is_empty() {
1393            return Ok(RedactConfig::default());
1394        }
1395        let file: RawRedactFile = toml::from_str(&text)
1396            .map_err(|error| ConfigError::Invalid(format!("parse redact config: {error}")))?;
1397        Ok(RedactConfig {
1398            enabled: file.redact.enabled,
1399            partial: file.redact.mode.as_deref() == Some("partial"),
1400            allowlist: file.redact.allowlist,
1401            custom_patterns: file
1402                .redact
1403                .custom_patterns
1404                .into_iter()
1405                .map(|pattern| (pattern.kind, pattern.regex))
1406                .collect(),
1407        })
1408    }
1409
1410    pub fn upsert_model(&self, update: ModelConfigUpdate<'_>) -> Result<(), ConfigError> {
1411        self.update_config_toml(|doc| {
1412            validate_model_name(doc, update.old_name, update.name)?;
1413            crate::model_registry::apply_model_config_update(doc, update)
1414                .map_err(|error| ConfigError::Invalid(error.to_string()))
1415        })
1416    }
1417
1418    pub fn remove_model(&self, name: &str) -> Result<(), ConfigError> {
1419        self.update_config_toml(|doc| {
1420            let aliases = table_entries(doc, "alias")?;
1421            let mut dependents = aliases
1422                .into_iter()
1423                .filter_map(|(alias, entry)| {
1424                    (entry.get("model").and_then(toml_edit::Item::as_str) == Some(name))
1425                        .then_some(alias)
1426                })
1427                .collect::<Vec<_>>();
1428            dependents.sort();
1429            if !dependents.is_empty() {
1430                return Err(ConfigError::Invalid(format!(
1431                    "model `{name}` is referenced by aliases: {}",
1432                    dependents.join(", ")
1433                )));
1434            }
1435            let models = required_table_mut(doc, "models")?;
1436            if models.remove(name).is_none() {
1437                return Err(ConfigError::Invalid(format!(
1438                    "config model `{name}` does not exist"
1439                )));
1440            }
1441            Ok(())
1442        })
1443    }
1444
1445    pub fn upsert_provider(&self, update: ProviderConfigUpdate<'_>) -> Result<(), ConfigError> {
1446        crate::provider_lifecycle::upsert_config_provider_for_hub(self, update)
1447    }
1448
1449    pub fn remove_provider(&self, name: &str) -> Result<(), ConfigError> {
1450        crate::provider_lifecycle::remove_config_provider_for_hub(self, name)
1451    }
1452
1453    #[cfg(test)]
1454    pub(crate) fn create_provider(
1455        &self,
1456        update: ProviderConfigUpdate<'_>,
1457    ) -> Result<crate::model_registry::ProviderEntry, ConfigError> {
1458        self.write_provider_config(update, ProviderConfigWriteMode::Create)
1459    }
1460
1461    #[cfg(test)]
1462    pub(crate) fn update_provider(
1463        &self,
1464        update: ProviderConfigUpdate<'_>,
1465    ) -> Result<crate::model_registry::ProviderEntry, ConfigError> {
1466        self.write_provider_config(update, ProviderConfigWriteMode::Update)
1467    }
1468
1469    #[cfg(test)]
1470    fn write_provider_config(
1471        &self,
1472        update: ProviderConfigUpdate<'_>,
1473        mode: ProviderConfigWriteMode,
1474    ) -> Result<crate::model_registry::ProviderEntry, ConfigError> {
1475        self.write_provider_config_and_then(update, mode, |_| ())
1476            .map(|(entry, ())| entry)
1477    }
1478
1479    pub(crate) fn write_provider_config_and_then<T>(
1480        &self,
1481        update: ProviderConfigUpdate<'_>,
1482        mode: ProviderConfigWriteMode,
1483        after_commit: impl FnOnce(&crate::model_registry::ProviderEntry) -> T,
1484    ) -> Result<(crate::model_registry::ProviderEntry, T), ConfigError> {
1485        self.update_config_toml_and_then(
1486            |doc| {
1487                if doc.get("providers").is_none() {
1488                    doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
1489                }
1490                let providers = doc
1491                    .get_mut("providers")
1492                    .and_then(toml_edit::Item::as_table_mut)
1493                    .ok_or_else(|| ConfigError::Invalid("providers is not a table".into()))?;
1494                let exists = providers.contains_key(update.name);
1495                match (mode, exists) {
1496                    (ProviderConfigWriteMode::Create, true) => {
1497                        return Err(ConfigError::NameConflict {
1498                            name: update.name.to_string(),
1499                            domain: "providers",
1500                        });
1501                    }
1502                    (ProviderConfigWriteMode::Update, false) => {
1503                        return Err(ConfigError::Invalid(format!(
1504                            "config provider `{}` does not exist",
1505                            update.name
1506                        )));
1507                    }
1508                    _ => {}
1509                }
1510                let reasoning_format = match update.reasoning_format {
1511                    Some(value) => Some(value),
1512                    None => providers
1513                        .get(update.name)
1514                        .and_then(toml_edit::Item::as_table)
1515                        .and_then(|entry| entry.get("reasoning_format"))
1516                        .and_then(toml_edit::Item::as_str)
1517                        .map(str::parse)
1518                        .transpose()
1519                        .map_err(ConfigError::Invalid)?,
1520                };
1521                let prompt_cache_key = update.prompt_cache_key.or_else(|| {
1522                    providers
1523                        .get(update.name)
1524                        .and_then(toml_edit::Item::as_table)
1525                        .and_then(|entry| entry.get("prompt_cache_key"))
1526                        .and_then(toml_edit::Item::as_bool)
1527                });
1528                let mut entry = toml_edit::Table::new();
1529                entry.insert("kind", toml_edit::value(update.kind));
1530                insert_nonempty(&mut entry, "api_key", update.api_key);
1531                insert_nonempty(&mut entry, "api_key_env", update.api_key_env);
1532                insert_nonempty(&mut entry, "base_url", update.base_url);
1533                if let Some(value) = update.max_tokens {
1534                    entry.insert("max_tokens", toml_edit::value(i64::from(value)));
1535                }
1536                if let Some(value) = reasoning_format {
1537                    entry.insert("reasoning_format", toml_edit::value(value.to_string()));
1538                }
1539                if let Some(value) = prompt_cache_key {
1540                    entry.insert("prompt_cache_key", toml_edit::value(value));
1541                }
1542                entry.insert("enabled", toml_edit::value(update.enabled));
1543                providers.insert(update.name, toml_edit::Item::Table(entry));
1544                Ok(crate::model_registry::ProviderEntry {
1545                    name: update.name.to_string(),
1546                    kind: update.kind.to_string(),
1547                    api_key: update
1548                        .api_key
1549                        .filter(|value| !value.is_empty())
1550                        .map(str::to_string),
1551                    api_key_env: update
1552                        .api_key_env
1553                        .filter(|value| !value.is_empty())
1554                        .map(str::to_string),
1555                    base_url: update
1556                        .base_url
1557                        .filter(|value| !value.is_empty())
1558                        .map(str::to_string),
1559                    max_tokens: update.max_tokens,
1560                    reasoning_format,
1561                    prompt_cache_key,
1562                    enabled: Some(update.enabled),
1563                })
1564            },
1565            after_commit,
1566        )
1567    }
1568
1569    pub fn add_alias(&self, alias: &str, model: &str) -> Result<(), ConfigError> {
1570        self.update_alias(None, alias, model)
1571    }
1572
1573    pub fn bind_default_model(&self, model: &str) -> Result<(), ConfigError> {
1574        self.update_config_toml(|doc| {
1575            if table_contains(doc, "models", "smart")? {
1576                return Err(ConfigError::NameConflict {
1577                    name: "smart".into(),
1578                    domain: "models",
1579                });
1580            }
1581            ensure_alias_table(doc)?;
1582            let aliases = doc
1583                .get_mut("alias")
1584                .and_then(toml_edit::Item::as_table_mut)
1585                .ok_or_else(|| ConfigError::Invalid("alias is not a table".into()))?;
1586            set_alias_model(aliases, "smart", model);
1587            if !aliases.contains_key("cheap") {
1588                set_alias_model(aliases, "cheap", "smart");
1589            }
1590            Ok(())
1591        })
1592    }
1593
1594    pub fn update_alias(
1595        &self,
1596        old_alias: Option<&str>,
1597        new_alias: &str,
1598        model: &str,
1599    ) -> Result<(), ConfigError> {
1600        self.update_config_toml(|doc| {
1601            validate_alias_name(doc, old_alias, new_alias)?;
1602            if doc.get("alias").is_none() {
1603                doc.insert("alias", toml_edit::Item::Table(toml_edit::Table::new()));
1604            }
1605            let aliases = doc
1606                .get_mut("alias")
1607                .and_then(toml_edit::Item::as_table_mut)
1608                .ok_or_else(|| ConfigError::Invalid("alias is not a table".into()))?;
1609            if let Some(old) = old_alias.filter(|old| *old != new_alias) {
1610                aliases.remove(old);
1611            }
1612            let mut entry = toml_edit::Table::new();
1613            entry.insert("model", toml_edit::value(model));
1614            aliases.insert(new_alias, toml_edit::Item::Table(entry));
1615            Ok(())
1616        })
1617    }
1618
1619    pub fn remove_alias(&self, alias: &str) -> Result<(), ConfigError> {
1620        self.update_config_toml(|doc| {
1621            let Some(aliases) = doc.get_mut("alias") else {
1622                return Err(ConfigError::Invalid(format!(
1623                    "config alias `{alias}` does not exist"
1624                )));
1625            };
1626            let aliases = aliases
1627                .as_table_mut()
1628                .ok_or_else(|| ConfigError::Invalid("alias is not a table".into()))?;
1629            if aliases.remove(alias).is_none() {
1630                return Err(ConfigError::Invalid(format!(
1631                    "config alias `{alias}` does not exist"
1632                )));
1633            }
1634            Ok(())
1635        })
1636    }
1637
1638    pub(crate) fn remove_provider_config_and_then<T>(
1639        &self,
1640        name: &str,
1641        after_commit: impl FnOnce() -> T,
1642    ) -> Result<T, ConfigError> {
1643        self.update_config_toml_and_then(
1644            |doc| {
1645                let models = table_entries(doc, "models")?;
1646                let mut dependents = models
1647                    .into_iter()
1648                    .filter_map(|(model, entry)| {
1649                        (entry.get("provider").and_then(toml_edit::Item::as_str) == Some(name))
1650                            .then_some(model)
1651                    })
1652                    .collect::<Vec<_>>();
1653                dependents.sort();
1654                if !dependents.is_empty() {
1655                    return Err(ConfigError::Invalid(format!(
1656                        "provider `{name}` is referenced by models: {}",
1657                        dependents.join(", ")
1658                    )));
1659                }
1660                let Some(providers) = doc.get_mut("providers") else {
1661                    return Err(ConfigError::Invalid(format!(
1662                        "config provider `{name}` does not exist"
1663                    )));
1664                };
1665                let providers = providers
1666                    .as_table_mut()
1667                    .ok_or_else(|| ConfigError::Invalid("providers is not a table".into()))?;
1668                if providers.remove(name).is_none() {
1669                    return Err(ConfigError::Invalid(format!(
1670                        "config provider `{name}` does not exist"
1671                    )));
1672                }
1673                Ok(())
1674            },
1675            |_| after_commit(),
1676        )
1677        .map(|(_, result)| result)
1678    }
1679
1680    pub fn reload(&self) -> Result<(), ConfigError> {
1681        crate::provider_lifecycle::reload_config_providers_for_hub(self)
1682    }
1683
1684    pub(crate) fn reload_and_then<T>(
1685        &self,
1686        apply: impl FnOnce(crate::model_registry::ProviderConfig) -> T,
1687    ) -> Result<T, ConfigError> {
1688        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1689        let _file_lock = self.lock_config_file()?;
1690        let text = self.read_config_toml()?;
1691        let prepared = crate::model_registry::prepare_config_text(&text)
1692            .map_err(|error| ConfigError::Invalid(error.to_string()))?;
1693        let snapshot = prepared.snapshot();
1694        crate::model_registry::commit_prepared_config(prepared);
1695        Ok(apply(snapshot))
1696    }
1697
1698    pub fn model_config(
1699        &self,
1700    ) -> Result<Option<crate::model_registry::ProviderConfig>, ConfigError> {
1701        let text = self.read_config_toml()?;
1702        if text.trim().is_empty() {
1703            return Ok(None);
1704        }
1705        let document = text.parse::<toml_edit::DocumentMut>()?;
1706        if document.get("providers").is_none()
1707            && document.get("models").is_none()
1708            && document.get("alias").is_none()
1709        {
1710            return Ok(None);
1711        }
1712        let has_model_entries = ["providers", "models", "alias"].iter().any(|section| {
1713            document
1714                .get(section)
1715                .and_then(toml_edit::Item::as_table)
1716                .is_some_and(|table| !table.is_empty())
1717        });
1718        if !has_model_entries {
1719            return Ok(None);
1720        }
1721        crate::model_registry::parse_config(&text)
1722            .ok_or_else(|| ConfigError::Invalid("invalid model configuration".into()))
1723            .map(Some)
1724    }
1725
1726    pub fn load_mcp(&self) -> Vec<crate::mcp::McpServerConfig> {
1727        crate::mcp_config::load_from_dir(self.config_dir(), true)
1728    }
1729
1730    pub fn load_local_mcp(&self) -> Vec<crate::mcp::McpServerConfig> {
1731        crate::mcp_config::load_from_dir(self.config_dir(), false)
1732    }
1733
1734    pub fn save_mcp(&self, configs: &[crate::mcp::McpServerConfig]) -> Result<(), ConfigError> {
1735        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1736        self.write_mcp(configs)
1737    }
1738
1739    pub fn upsert_mcp(&self, config: crate::mcp::McpServerConfig) -> Result<(), ConfigError> {
1740        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1741        let mut configs = self.load_local_mcp();
1742        configs.retain(|current| current.name != config.name);
1743        configs.push(config);
1744        self.write_mcp(&configs)
1745    }
1746
1747    pub fn replace_mcp(
1748        &self,
1749        original_name: &str,
1750        config: crate::mcp::McpServerConfig,
1751    ) -> Result<(), ConfigError> {
1752        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1753        let mut configs = self.load_local_mcp();
1754        let index = configs
1755            .iter()
1756            .position(|current| current.name == original_name)
1757            .ok_or_else(|| {
1758                ConfigError::Invalid(format!("MCP server {original_name:?} not found"))
1759            })?;
1760        if config.name != original_name && configs.iter().any(|current| current.name == config.name)
1761        {
1762            return Err(ConfigError::NameConflict {
1763                domain: "MCP servers",
1764                name: config.name,
1765            });
1766        }
1767        configs[index] = config;
1768        self.write_mcp(&configs)
1769    }
1770
1771    pub fn toggle_mcp(&self, name: &str) -> Result<bool, ConfigError> {
1772        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1773        let mut configs = self.load_local_mcp();
1774        let config = configs
1775            .iter_mut()
1776            .find(|config| config.name == name)
1777            .ok_or_else(|| ConfigError::Invalid(format!("MCP server {name:?} not found")))?;
1778        config.disabled = !config.disabled;
1779        let disabled = config.disabled;
1780        self.write_mcp(&configs)?;
1781        Ok(disabled)
1782    }
1783
1784    pub fn remove_mcp(&self, name: &str) -> Result<(), ConfigError> {
1785        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1786        let mut configs = self.load_local_mcp();
1787        let before = configs.len();
1788        configs.retain(|config| config.name != name);
1789        if configs.len() == before {
1790            return Err(ConfigError::Invalid(format!(
1791                "MCP server {name:?} not found"
1792            )));
1793        }
1794        self.write_mcp(&configs)
1795    }
1796
1797    pub fn migrate_and_reload_models(
1798        &self,
1799    ) -> Result<crate::model_registry::ModelMigrationOutcome, ConfigError> {
1800        let outcome = self.migrate_model_config_if_needed()?;
1801        self.reload()?;
1802        Ok(outcome)
1803    }
1804
1805    pub fn migrate_model_config_if_needed(
1806        &self,
1807    ) -> Result<crate::model_registry::ModelMigrationOutcome, ConfigError> {
1808        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1809        let _file_lock = self.lock_config_file()?;
1810        let text = self.read_config_toml()?;
1811        let Some(migrated) = crate::model_registry::migrate_config_if_needed(&text)? else {
1812            return Ok(crate::model_registry::ModelMigrationOutcome::NotNeeded);
1813        };
1814        let backup = self.config_dir.join("config.toml.bak");
1815        write_sensitive_create_new_or_same(&backup, text.as_bytes())?;
1816        self.write_config_toml(&migrated)?;
1817        Ok(crate::model_registry::ModelMigrationOutcome::Migrated { backup })
1818    }
1819
1820    fn lock_config_file(&self) -> Result<std::fs::File, ConfigError> {
1821        lock_file(&self.config_dir.join(".config.toml.lock"))
1822    }
1823
1824    fn update_config_toml<T>(
1825        &self,
1826        mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<T, ConfigError>,
1827    ) -> Result<T, ConfigError> {
1828        self.update_config_toml_and_then(mutate, |_| ())
1829            .map(|(result, ())| result)
1830    }
1831
1832    fn update_config_toml_and_then<T, U>(
1833        &self,
1834        mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<T, ConfigError>,
1835        after_commit: impl FnOnce(&T) -> U,
1836    ) -> Result<(T, U), ConfigError> {
1837        let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
1838        let _file_lock = self.lock_config_file()?;
1839        let text = self.read_config_toml()?;
1840        let mut doc = if text.trim().is_empty() {
1841            toml_edit::DocumentMut::new()
1842        } else {
1843            text.parse()?
1844        };
1845        let result = mutate(&mut doc)?;
1846        let new_text = doc.to_string();
1847        let prepared = crate::model_registry::prepare_config_text(&new_text)
1848            .map_err(|error| ConfigError::Invalid(error.to_string()))?;
1849        self.write_config_toml(&new_text)?;
1850        crate::model_registry::commit_prepared_config(prepared);
1851        let after_commit = after_commit(&result);
1852        Ok((result, after_commit))
1853    }
1854
1855    fn write_config_toml(&self, text: &str) -> Result<(), ConfigError> {
1856        write_unique_atomic(&self.config_toml_path(), text.as_bytes())
1857    }
1858
1859    fn write_daemon_config(&self, config: &DaemonConfig) -> Result<(), ConfigError> {
1860        let text = toml::to_string(config)
1861            .map_err(|error| ConfigError::Invalid(format!("serialize daemon config: {error}")))?;
1862        let path = self
1863            .daemon_config_path
1864            .as_deref()
1865            .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
1866        write_sensitive_atomic(path, text.as_bytes())
1867    }
1868
1869    fn write_auth_document(
1870        &self,
1871        document: &crate::auth_store::AuthStoreDocument,
1872    ) -> Result<(), ConfigError> {
1873        let json = serde_json::to_vec_pretty(document)
1874            .map_err(|error| ConfigError::Invalid(format!("serialize auth store: {error}")))?;
1875        write_sensitive_atomic(&self.auth_path, &json)
1876    }
1877
1878    fn write_mcp(&self, configs: &[crate::mcp::McpServerConfig]) -> Result<(), ConfigError> {
1879        let json = crate::mcp_config::serialize(configs)
1880            .map_err(|error| ConfigError::Invalid(format!("serialize mcp config: {error}")))?;
1881        self.write_atomic("mcp_servers.json", ".mcp_servers.json.tmp", &json)
1882    }
1883
1884    fn write_atomic(
1885        &self,
1886        filename: &str,
1887        temp_filename: &str,
1888        text: &str,
1889    ) -> Result<(), ConfigError> {
1890        std::fs::create_dir_all(&self.config_dir)?;
1891        let tmp = self.config_dir.join(temp_filename);
1892        std::fs::write(&tmp, text)?;
1893        std::fs::rename(tmp, self.config_dir.join(filename))?;
1894        Ok(())
1895    }
1896}
1897
1898fn auth_provider_matches(
1899    current: &crate::auth_store::StoredProvider,
1900    expected: &crate::auth_store::StoredProvider,
1901) -> bool {
1902    current.id == expected.id && current.name == expected.name && current.kind == expected.kind
1903}
1904
1905fn sorted_auth_provider_ids(store: &crate::auth_store::AuthStore) -> Vec<String> {
1906    let mut provider_ids = store
1907        .providers
1908        .iter()
1909        .map(|provider| provider.id.clone())
1910        .collect::<Vec<_>>();
1911    provider_ids.sort();
1912    provider_ids
1913}
1914
1915fn dsl_route_source(flow_name: &str, trigger: &str) -> Result<String, ConfigError> {
1916    if syn_identifier(flow_name).is_none() {
1917        return Err(ConfigError::Invalid(format!(
1918            "route flow {flow_name:?} is not a valid DSL identifier"
1919        )));
1920    }
1921    if trigger.is_empty() {
1922        return Err(ConfigError::Invalid(
1923            "route trigger must not be empty".to_string(),
1924        ));
1925    }
1926    let trigger = format!("{trigger:?}");
1927    let route = format!("route {trigger} {{ flow: {flow_name} }}\n");
1928    parse_routes_source("generated route", &route)?;
1929    Ok(route)
1930}
1931
1932fn syn_identifier(value: &str) -> Option<()> {
1933    let source = format!("flow {value}() {{}}\n");
1934    atman_dsl::parse::parse_file(&source).ok().map(|_| ())
1935}
1936
1937fn parse_routes_source(context: &str, source: &str) -> Result<(), ConfigError> {
1938    if source.is_empty() {
1939        return Ok(());
1940    }
1941    atman_dsl::parse::parse_file(source)
1942        .map(|_| ())
1943        .map_err(|error| ConfigError::Invalid(format!("parse {context}: {error}")))
1944}
1945
1946fn lock_path_for(path: &Path) -> PathBuf {
1947    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1948    let name = path
1949        .file_name()
1950        .and_then(|name| name.to_str())
1951        .unwrap_or("config");
1952    parent.join(format!(".{name}.lock"))
1953}
1954
1955fn lock_file(path: &Path) -> Result<std::fs::File, ConfigError> {
1956    use fs2::FileExt;
1957    std::fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new(".")))?;
1958    let lock = std::fs::OpenOptions::new()
1959        .read(true)
1960        .write(true)
1961        .create(true)
1962        .truncate(false)
1963        .open(path)?;
1964    lock.lock_exclusive()?;
1965    Ok(lock)
1966}
1967
1968fn write_unique_atomic(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
1969    use std::io::Write;
1970
1971    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1972    std::fs::create_dir_all(parent)?;
1973    let filename = path
1974        .file_name()
1975        .and_then(|name| name.to_str())
1976        .unwrap_or("config");
1977    let tmp = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4().simple()));
1978    let result = (|| -> Result<(), ConfigError> {
1979        let mut file = std::fs::OpenOptions::new()
1980            .write(true)
1981            .create_new(true)
1982            .open(&tmp)?;
1983        file.write_all(contents)?;
1984        file.sync_all()?;
1985        drop(file);
1986        std::fs::rename(&tmp, path)?;
1987        Ok(())
1988    })();
1989    if result.is_err() {
1990        let _ = std::fs::remove_file(&tmp);
1991    }
1992    result
1993}
1994
1995fn load_auth_from_path(path: &Path) -> Result<crate::auth_store::AuthStore, ConfigError> {
1996    Ok(load_auth_document_from_path(path)?.legacy_view())
1997}
1998
1999fn load_auth_document_from_path(
2000    path: &Path,
2001) -> Result<crate::auth_store::AuthStoreDocument, ConfigError> {
2002    match std::fs::read(path) {
2003        Ok(bytes) => serde_json::from_slice(&bytes)
2004            .map_err(|error| ConfigError::Invalid(format!("parse {}: {error}", path.display()))),
2005        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2006            Ok(crate::auth_store::AuthStoreDocument::default())
2007        }
2008        Err(error) => Err(error.into()),
2009    }
2010}
2011
2012fn write_sensitive_create_new_or_same(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
2013    match std::fs::read(path) {
2014        Ok(existing) if existing == contents => return Ok(()),
2015        Ok(_) => {
2016            return Err(ConfigError::Invalid(format!(
2017                "backup conflict at {}",
2018                path.display()
2019            )));
2020        }
2021        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2022        Err(error) => return Err(error.into()),
2023    }
2024    let parent = path.parent().unwrap_or_else(|| Path::new("."));
2025    std::fs::create_dir_all(parent)?;
2026    let mut options = std::fs::OpenOptions::new();
2027    options.write(true).create_new(true);
2028    #[cfg(unix)]
2029    {
2030        use std::os::unix::fs::OpenOptionsExt;
2031        options.mode(0o600);
2032    }
2033    use std::io::Write;
2034    let mut file = options.open(path)?;
2035    set_sensitive_file_permissions(path)?;
2036    if let Err(error) = file.write_all(contents).and_then(|_| file.sync_all()) {
2037        drop(file);
2038        let _ = std::fs::remove_file(path);
2039        return Err(error.into());
2040    }
2041    Ok(())
2042}
2043
2044fn write_sensitive_atomic(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
2045    let parent = path.parent().unwrap_or_else(|| Path::new("."));
2046    std::fs::create_dir_all(parent)?;
2047    let filename = path
2048        .file_name()
2049        .and_then(|name| name.to_str())
2050        .unwrap_or("sensitive-config");
2051    let tmp = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4().simple()));
2052    let mut options = std::fs::OpenOptions::new();
2053    options.write(true).create_new(true);
2054    #[cfg(unix)]
2055    {
2056        use std::os::unix::fs::OpenOptionsExt;
2057        options.mode(0o600);
2058    }
2059    let mut file = options.open(&tmp)?;
2060    set_sensitive_file_permissions(&tmp)?;
2061    use std::io::Write;
2062    file.write_all(contents)?;
2063    drop(file);
2064    std::fs::rename(&tmp, path)?;
2065    Ok(())
2066}
2067
2068fn generate_daemon_token() -> String {
2069    let first = uuid::Uuid::new_v4().simple().to_string();
2070    let second = uuid::Uuid::new_v4().simple().to_string();
2071    format!("{first}{second}")
2072}
2073
2074fn set_sensitive_file_permissions(path: &Path) -> Result<(), ConfigError> {
2075    #[cfg(unix)]
2076    {
2077        use std::os::unix::fs::PermissionsExt;
2078        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
2079    }
2080    Ok(())
2081}
2082
2083fn insert_nonempty(table: &mut toml_edit::Table, key: &str, value: Option<&str>) {
2084    if let Some(value) = value.filter(|value| !value.is_empty()) {
2085        table.insert(key, toml_edit::value(value));
2086    }
2087}
2088
2089fn ensure_alias_table(doc: &mut toml_edit::DocumentMut) -> Result<(), ConfigError> {
2090    if doc.get("alias").is_none() {
2091        doc.insert("alias", toml_edit::Item::Table(toml_edit::Table::new()));
2092    }
2093    if doc
2094        .get("alias")
2095        .and_then(toml_edit::Item::as_table)
2096        .is_none()
2097    {
2098        return Err(ConfigError::Invalid("alias is not a table".into()));
2099    }
2100    Ok(())
2101}
2102
2103fn set_alias_model(table: &mut toml_edit::Table, alias: &str, model: &str) {
2104    let mut entry = toml_edit::Table::new();
2105    entry.insert("model", toml_edit::value(model));
2106    table.insert(alias, toml_edit::Item::Table(entry));
2107}
2108
2109fn validate_model_name(
2110    doc: &toml_edit::DocumentMut,
2111    old_name: Option<&str>,
2112    name: &str,
2113) -> Result<(), ConfigError> {
2114    if old_name != Some(name) && table_contains(doc, "models", name)? {
2115        return Err(ConfigError::NameConflict {
2116            name: name.into(),
2117            domain: "models",
2118        });
2119    }
2120    if table_contains(doc, "alias", name)? {
2121        return Err(ConfigError::NameConflict {
2122            name: name.into(),
2123            domain: "alias",
2124        });
2125    }
2126    Ok(())
2127}
2128
2129fn validate_alias_name(
2130    doc: &toml_edit::DocumentMut,
2131    old_name: Option<&str>,
2132    name: &str,
2133) -> Result<(), ConfigError> {
2134    if table_contains(doc, "models", name)? {
2135        return Err(ConfigError::NameConflict {
2136            name: name.into(),
2137            domain: "models",
2138        });
2139    }
2140    if old_name != Some(name) && table_contains(doc, "alias", name)? {
2141        return Err(ConfigError::NameConflict {
2142            name: name.into(),
2143            domain: "alias",
2144        });
2145    }
2146    Ok(())
2147}
2148
2149fn table_contains(
2150    doc: &toml_edit::DocumentMut,
2151    table: &'static str,
2152    name: &str,
2153) -> Result<bool, ConfigError> {
2154    match doc.get(table) {
2155        None => Ok(false),
2156        Some(item) => item
2157            .as_table()
2158            .map(|items| items.contains_key(name))
2159            .ok_or_else(|| ConfigError::Invalid(format!("{table} is not a table"))),
2160    }
2161}
2162
2163fn table_entries<'a>(
2164    doc: &'a toml_edit::DocumentMut,
2165    table: &'static str,
2166) -> Result<Vec<(String, &'a toml_edit::Table)>, ConfigError> {
2167    match doc.get(table) {
2168        None => Ok(Vec::new()),
2169        Some(item) => item
2170            .as_table()
2171            .ok_or_else(|| ConfigError::Invalid(format!("{table} is not a table")))?
2172            .iter()
2173            .map(|(name, item)| {
2174                item.as_table()
2175                    .map(|entry| (name.to_string(), entry))
2176                    .ok_or_else(|| ConfigError::Invalid(format!("{table}.{name} is not a table")))
2177            })
2178            .collect(),
2179    }
2180}
2181
2182fn required_table_mut<'a>(
2183    doc: &'a mut toml_edit::DocumentMut,
2184    table: &'static str,
2185) -> Result<&'a mut toml_edit::Table, ConfigError> {
2186    doc.get_mut(table)
2187        .and_then(toml_edit::Item::as_table_mut)
2188        .ok_or_else(|| ConfigError::Invalid(format!("{table} is not a table")))
2189}
2190
2191#[cfg(test)]
2192mod tests {
2193    use super::*;
2194
2195    struct ProviderRegistryReset;
2196
2197    impl Drop for ProviderRegistryReset {
2198        fn drop(&mut self) {
2199            crate::model_registry::set_provider_config(Default::default());
2200        }
2201    }
2202
2203    fn temp_hub() -> (tempfile::TempDir, ConfigHub) {
2204        let dir = tempfile::tempdir().unwrap();
2205        let hub = ConfigHub::from_config_dir(dir.path());
2206        (dir, hub)
2207    }
2208
2209    #[test]
2210    fn settings_mutation_validation_is_centralized() {
2211        let (_dir, hub) = temp_hub();
2212        assert!(hub.validate_setting_mutation("trust.mode", "allow").is_ok());
2213        assert!(hub.validate_setting_mutation("trust.mode", " ").is_err());
2214        assert!(hub.validate_setting_mutation("missing", "x").is_err());
2215    }
2216
2217    #[test]
2218    fn provider_reasoning_format_is_written_and_preserved() {
2219        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK
2220            .lock()
2221            .unwrap_or_else(std::sync::PoisonError::into_inner);
2222        let (_dir, hub) = temp_hub();
2223        let base = ProviderConfigUpdate {
2224            name: "gateway",
2225            kind: "openai-compat",
2226            api_key: None,
2227            api_key_env: Some("GATEWAY_KEY"),
2228            base_url: Some("https://gateway.example/v1"),
2229            max_tokens: None,
2230            reasoning_format: Some(crate::providers::openai::OpenAiReasoningFormat::Official),
2231            prompt_cache_key: Some(true),
2232            enabled: true,
2233        };
2234        hub.upsert_provider(base).unwrap();
2235        hub.upsert_provider(ProviderConfigUpdate {
2236            reasoning_format: None,
2237            prompt_cache_key: None,
2238            enabled: false,
2239            ..base
2240        })
2241        .unwrap();
2242
2243        let config = std::fs::read_to_string(hub.config_toml_path()).unwrap();
2244        assert!(config.contains("reasoning_format = \"reasoning-effort\""));
2245        assert!(config.contains("prompt_cache_key = true"));
2246        assert!(config.contains("enabled = false"));
2247        assert_eq!(
2248            hub.model_config().unwrap().unwrap().providers["gateway"].prompt_cache_key,
2249            Some(true)
2250        );
2251    }
2252
2253    #[test]
2254    fn provider_create_returns_the_committed_snapshot() {
2255        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK
2256            .lock()
2257            .unwrap_or_else(std::sync::PoisonError::into_inner);
2258        let _reset = ProviderRegistryReset;
2259        let (_dir, hub) = temp_hub();
2260
2261        let snapshot = hub
2262            .create_provider(ProviderConfigUpdate {
2263                name: "gateway",
2264                kind: "openai-compat",
2265                api_key: Some("inline-key"),
2266                api_key_env: Some("GATEWAY_KEY"),
2267                base_url: Some("https://gateway.example/v1"),
2268                max_tokens: Some(16_384),
2269                reasoning_format: Some(crate::providers::openai::OpenAiReasoningFormat::Official),
2270                prompt_cache_key: None,
2271                enabled: false,
2272            })
2273            .unwrap();
2274
2275        assert_eq!(snapshot.name, "gateway");
2276        assert_eq!(snapshot.kind, "openai-compat");
2277        assert_eq!(snapshot.api_key.as_deref(), Some("inline-key"));
2278        assert_eq!(snapshot.api_key_env.as_deref(), Some("GATEWAY_KEY"));
2279        assert_eq!(
2280            snapshot.base_url.as_deref(),
2281            Some("https://gateway.example/v1")
2282        );
2283        assert_eq!(snapshot.max_tokens, Some(16_384));
2284        assert_eq!(
2285            snapshot.reasoning_format,
2286            Some(crate::providers::openai::OpenAiReasoningFormat::Official)
2287        );
2288        assert_eq!(snapshot.enabled, Some(false));
2289
2290        let committed = hub.model_config().unwrap().unwrap().providers["gateway"].clone();
2291        assert_eq!(committed.name, snapshot.name);
2292        assert_eq!(committed.kind, snapshot.kind);
2293        assert_eq!(committed.api_key, snapshot.api_key);
2294        assert_eq!(committed.api_key_env, snapshot.api_key_env);
2295        assert_eq!(committed.base_url, snapshot.base_url);
2296        assert_eq!(committed.max_tokens, snapshot.max_tokens);
2297        assert_eq!(committed.reasoning_format, snapshot.reasoning_format);
2298        assert_eq!(committed.enabled, snapshot.enabled);
2299    }
2300
2301    #[test]
2302    fn provider_create_conflict_does_not_overwrite_the_existing_entry() {
2303        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK
2304            .lock()
2305            .unwrap_or_else(std::sync::PoisonError::into_inner);
2306        let _reset = ProviderRegistryReset;
2307        let (_dir, hub) = temp_hub();
2308        let initial = ProviderConfigUpdate {
2309            name: "gateway",
2310            kind: "openai-compat",
2311            api_key: Some("first-key"),
2312            api_key_env: None,
2313            base_url: Some("https://first.example/v1"),
2314            max_tokens: Some(8_192),
2315            reasoning_format: None,
2316            prompt_cache_key: None,
2317            enabled: true,
2318        };
2319        hub.create_provider(initial).unwrap();
2320        let before = hub.read_config_toml().unwrap();
2321
2322        let error = hub
2323            .create_provider(ProviderConfigUpdate {
2324                kind: "anthropic",
2325                api_key: Some("second-key"),
2326                base_url: Some("https://second.example/v1"),
2327                ..initial
2328            })
2329            .unwrap_err();
2330
2331        assert!(matches!(
2332            error,
2333            ConfigError::NameConflict {
2334                ref name,
2335                domain: "providers"
2336            } if name == "gateway"
2337        ));
2338        assert_eq!(hub.read_config_toml().unwrap(), before);
2339        let committed = &hub.model_config().unwrap().unwrap().providers["gateway"];
2340        assert_eq!(committed.kind, "openai-compat");
2341        assert_eq!(committed.api_key.as_deref(), Some("first-key"));
2342        assert_eq!(
2343            committed.base_url.as_deref(),
2344            Some("https://first.example/v1")
2345        );
2346    }
2347
2348    #[test]
2349    fn provider_update_missing_does_not_create_an_entry() {
2350        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK
2351            .lock()
2352            .unwrap_or_else(std::sync::PoisonError::into_inner);
2353        let _reset = ProviderRegistryReset;
2354        let (_dir, hub) = temp_hub();
2355        let before = hub.read_config_toml().unwrap();
2356
2357        let error = hub
2358            .update_provider(ProviderConfigUpdate {
2359                name: "missing",
2360                kind: "openai-compat",
2361                api_key: None,
2362                api_key_env: None,
2363                base_url: Some("https://gateway.example/v1"),
2364                max_tokens: None,
2365                reasoning_format: None,
2366                prompt_cache_key: None,
2367                enabled: true,
2368            })
2369            .unwrap_err();
2370
2371        assert!(error.to_string().contains("does not exist"));
2372        assert_eq!(hub.read_config_toml().unwrap(), before);
2373        assert!(hub.model_config().unwrap().is_none());
2374    }
2375
2376    #[test]
2377    fn reload_reads_and_commits_under_the_config_write_lock() {
2378        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK
2379            .lock()
2380            .unwrap_or_else(std::sync::PoisonError::into_inner);
2381        let _reset = ProviderRegistryReset;
2382        let (_dir, hub) = temp_hub();
2383        std::fs::write(
2384            hub.config_toml_path(),
2385            "[providers.gateway]\nkind = \"openai-compat\"\napi_key = \"old-key\"\nenabled = true\n",
2386        )
2387        .unwrap();
2388
2389        let config_guard = CONFIG_WRITE_LOCK.lock().unwrap();
2390        let (started_tx, started_rx) = std::sync::mpsc::channel();
2391        let (finished_tx, finished_rx) = std::sync::mpsc::channel();
2392        let reload_hub = hub.clone();
2393        let reload = std::thread::spawn(move || {
2394            started_tx.send(()).unwrap();
2395            finished_tx.send(reload_hub.reload()).unwrap();
2396        });
2397        started_rx.recv().unwrap();
2398        std::thread::sleep(std::time::Duration::from_millis(50));
2399        assert!(matches!(
2400            finished_rx.try_recv(),
2401            Err(std::sync::mpsc::TryRecvError::Empty)
2402        ));
2403        std::fs::write(
2404            hub.config_toml_path(),
2405            "[providers.gateway]\nkind = \"openai-compat\"\napi_key = \"new-key\"\nenabled = false\n",
2406        )
2407        .unwrap();
2408        drop(config_guard);
2409
2410        finished_rx.recv().unwrap().unwrap();
2411        reload.join().unwrap();
2412        let projected = crate::model_registry::all_provider_entries()
2413            .into_iter()
2414            .find(|(name, _)| name == "gateway")
2415            .map(|(_, entry)| entry)
2416            .unwrap();
2417        assert_eq!(projected.api_key.as_deref(), Some("new-key"));
2418        assert_eq!(projected.enabled, Some(false));
2419    }
2420
2421    #[test]
2422    fn tool_output_budget_uses_defaults_and_reads_overrides() {
2423        let (dir, hub) = temp_hub();
2424        assert_eq!(
2425            hub.tool_output_budget().unwrap(),
2426            crate::tools::tool_output::ToolOutputBudget {
2427                max_lines: 256,
2428                max_bytes: 10 * 1024,
2429                max_line_bytes: 10 * 1024,
2430            }
2431        );
2432        write_config(
2433            &hub,
2434            "[tool_output]\nmax_lines = 7\nmax_bytes = 777\nmax_line_bytes = 111\n",
2435        );
2436        assert_eq!(
2437            hub.tool_output_budget().unwrap(),
2438            crate::tools::tool_output::ToolOutputBudget {
2439                max_lines: 7,
2440                max_bytes: 777,
2441                max_line_bytes: 111,
2442            }
2443        );
2444        let _ = dir;
2445    }
2446
2447    #[test]
2448    fn tool_output_budget_rejects_zero_values() {
2449        let (_dir, hub) = temp_hub();
2450        write_config(&hub, "[tool_output]\nmax_bytes = 0\n");
2451        assert!(hub.tool_output_budget().is_err());
2452    }
2453
2454    #[test]
2455    fn storage_config_merges_only_typed_storage_projection() {
2456        let (dir, hub) = temp_hub();
2457        std::fs::write(
2458            dir.path().join("config.toml"),
2459            "[storage]\nscope = \"local\"\n[theme]\nmode = \"dark\"\n",
2460        )
2461        .unwrap();
2462        let project = tempfile::tempdir().unwrap();
2463        std::fs::create_dir(project.path().join(".atman")).unwrap();
2464        std::fs::write(
2465            project.path().join(".atman/config.toml"),
2466            "[storage]\nscope = \"global\"\n[theme]\nmode = \"light\"\n",
2467        )
2468        .unwrap();
2469
2470        assert_eq!(
2471            hub.storage_config(Some(project.path())).scope,
2472            Some(crate::storage::StorageScope::Global)
2473        );
2474    }
2475
2476    #[test]
2477    fn set_project_storage_scope_preserves_other_project_config() {
2478        let (_dir, hub) = temp_hub();
2479        let project = tempfile::tempdir().unwrap();
2480        let config_dir = project.path().join(".atman");
2481        std::fs::create_dir(&config_dir).unwrap();
2482        std::fs::write(
2483            config_dir.join("config.toml"),
2484            "# keep this comment\n[theme]\nmode = \"dark\"\n",
2485        )
2486        .unwrap();
2487
2488        hub.set_project_storage_scope(project.path(), crate::storage::StorageScope::Local)
2489            .unwrap();
2490        let text = std::fs::read_to_string(config_dir.join("config.toml")).unwrap();
2491        assert!(text.contains("# keep this comment"));
2492        assert!(text.contains("[theme]"));
2493        assert_eq!(
2494            hub.storage_config(Some(project.path())).scope,
2495            Some(crate::storage::StorageScope::Local)
2496        );
2497
2498        hub.set_project_storage_scope(project.path(), crate::storage::StorageScope::Global)
2499            .unwrap();
2500        assert_eq!(
2501            hub.storage_config(Some(project.path())).scope,
2502            Some(crate::storage::StorageScope::Global)
2503        );
2504    }
2505
2506    #[test]
2507    fn storage_config_isolates_invalid_global_and_project_layers() {
2508        let (dir, hub) = temp_hub();
2509        let project = tempfile::tempdir().unwrap();
2510        std::fs::create_dir(project.path().join(".atman")).unwrap();
2511        std::fs::write(dir.path().join("config.toml"), "not valid [").unwrap();
2512        std::fs::write(
2513            project.path().join(".atman/config.toml"),
2514            "[storage]\nscope = \"local\"\n",
2515        )
2516        .unwrap();
2517        assert_eq!(
2518            hub.storage_config(Some(project.path())).scope,
2519            Some(crate::storage::StorageScope::Local)
2520        );
2521
2522        std::fs::write(
2523            dir.path().join("config.toml"),
2524            "[storage]\nscope = \"global\"\n",
2525        )
2526        .unwrap();
2527        std::fs::write(project.path().join(".atman/config.toml"), "not valid [").unwrap();
2528        assert_eq!(
2529            hub.storage_config(Some(project.path())).scope,
2530            Some(crate::storage::StorageScope::Global)
2531        );
2532    }
2533
2534    #[test]
2535    fn storage_config_treats_read_errors_as_empty_layers() {
2536        let (dir, hub) = temp_hub();
2537        std::fs::create_dir(dir.path().join("config.toml")).unwrap();
2538        let project = tempfile::tempdir().unwrap();
2539        std::fs::create_dir(project.path().join(".atman")).unwrap();
2540        std::fs::write(
2541            project.path().join(".atman/config.toml"),
2542            "[storage]\nscope = \"local\"\n",
2543        )
2544        .unwrap();
2545
2546        assert_eq!(
2547            hub.storage_config(Some(project.path())).scope,
2548            Some(crate::storage::StorageScope::Local)
2549        );
2550    }
2551
2552    fn write_config(hub: &ConfigHub, text: &str) {
2553        std::fs::write(hub.config_toml_path(), text).unwrap();
2554    }
2555
2556    #[test]
2557    fn append_dsl_route_creates_missing_file_and_escapes_trigger() {
2558        let (_dir, hub) = temp_hub();
2559        hub.append_dsl_route("review_code", "say \"hi\"\\now\n")
2560            .unwrap();
2561
2562        let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
2563        let parsed = atman_dsl::parse::parse_file(&source).unwrap();
2564        assert_eq!(parsed.routes.len(), 1);
2565        assert_eq!(parsed.routes[0].pattern, "say \"hi\"\\now\n");
2566        assert_eq!(parsed.routes[0].flow.name, "review_code");
2567    }
2568
2569    #[test]
2570    fn append_dsl_route_preserves_existing_source_exactly() {
2571        let (_dir, hub) = temp_hub();
2572        let original = "// keep this comment\nroute \"old \" { flow: old_flow }";
2573        std::fs::write(hub.routes_at_path(), original).unwrap();
2574
2575        hub.append_dsl_route("new_flow", "new ").unwrap();
2576
2577        assert_eq!(
2578            std::fs::read_to_string(hub.routes_at_path()).unwrap(),
2579            format!("{original}\nroute \"new \" {{ flow: new_flow }}\n")
2580        );
2581    }
2582
2583    #[test]
2584    fn append_dsl_route_does_not_overwrite_invalid_existing_source() {
2585        let (_dir, hub) = temp_hub();
2586        let invalid = "route invalid";
2587        std::fs::write(hub.routes_at_path(), invalid).unwrap();
2588
2589        let error = hub.append_dsl_route("new_flow", "new ").unwrap_err();
2590
2591        assert!(error.to_string().contains("parse existing routes.at"));
2592        assert_eq!(
2593            std::fs::read_to_string(hub.routes_at_path()).unwrap(),
2594            invalid
2595        );
2596    }
2597
2598    #[test]
2599    fn append_dsl_route_rejects_invalid_flow_without_writing() {
2600        let (_dir, hub) = temp_hub();
2601        let error = hub.append_dsl_route("bad-name", "new ").unwrap_err();
2602        assert!(error.to_string().contains("valid DSL identifier"));
2603        assert!(!hub.routes_at_path().exists());
2604    }
2605
2606    #[test]
2607    fn concurrent_dsl_route_appends_do_not_lose_updates() {
2608        let (_dir, hub) = temp_hub();
2609        let mut workers = Vec::new();
2610        for index in 0..12 {
2611            let hub = hub.clone();
2612            workers.push(std::thread::spawn(move || {
2613                hub.append_dsl_route(&format!("flow_{index}"), &format!("{index} "))
2614                    .unwrap();
2615            }));
2616        }
2617        for worker in workers {
2618            worker.join().unwrap();
2619        }
2620
2621        let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
2622        let parsed = atman_dsl::parse::parse_file(&source).unwrap();
2623        assert_eq!(parsed.routes.len(), 12);
2624        for index in 0..12 {
2625            assert!(parsed.routes.iter().any(|route| {
2626                route.flow.name == format!("flow_{index}") && route.pattern == format!("{index} ")
2627            }));
2628        }
2629        assert!(!std::fs::read_dir(hub.config_dir()).unwrap().any(|entry| {
2630            let name = entry.unwrap().file_name();
2631            let name = name.to_string_lossy();
2632            name.starts_with(".routes.at.") && name.ends_with(".tmp")
2633        }));
2634    }
2635
2636    #[test]
2637    fn append_dsl_route_waits_for_external_file_lock() {
2638        use fs2::FileExt;
2639        use std::sync::mpsc::TryRecvError;
2640
2641        let (_dir, hub) = temp_hub();
2642        let lock = std::fs::OpenOptions::new()
2643            .read(true)
2644            .write(true)
2645            .create(true)
2646            .truncate(false)
2647            .open(hub.config_dir().join(".routes.at.lock"))
2648            .unwrap();
2649        lock.lock_exclusive().unwrap();
2650
2651        let worker_hub = hub.clone();
2652        let (tx, rx) = std::sync::mpsc::channel();
2653        let worker = std::thread::spawn(move || {
2654            worker_hub.append_dsl_route("blocked", "wait ").unwrap();
2655            tx.send(()).unwrap();
2656        });
2657        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
2658        FileExt::unlock(&lock).unwrap();
2659        rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
2660        worker.join().unwrap();
2661    }
2662
2663    #[test]
2664    fn model_config_projection_handles_missing_valid_and_invalid_files() {
2665        let (_dir, hub) = temp_hub();
2666        assert!(hub.model_config().unwrap().is_none());
2667
2668        write_config(
2669            &hub,
2670            "[providers.openai]\nkind = \"openai\"\n[models.fast]\nmodel = \"gpt-4o-mini\"\n[alias.default]\nmodel = \"fast\"\n",
2671        );
2672        let config = hub.model_config().unwrap().unwrap();
2673        assert_eq!(config.providers["openai"].kind, "openai");
2674        assert_eq!(config.models["fast"].model, "gpt-4o-mini");
2675        assert_eq!(config.aliases["default"].model, "fast");
2676
2677        write_config(&hub, "[models]\n");
2678        assert!(hub.model_config().unwrap().is_none());
2679
2680        write_config(&hub, "[models\n");
2681        assert!(hub.model_config().is_err());
2682    }
2683
2684    #[test]
2685    fn config_crud_validates_model_semantics_before_disk_or_registry_changes() {
2686        let _registry = crate::model_registry::MODEL_CONFIG_LOCK
2687            .lock()
2688            .unwrap_or_else(std::sync::PoisonError::into_inner);
2689        let mut current = crate::model_registry::ProviderConfig::default();
2690        current.models.insert(
2691            "current".into(),
2692            crate::model_registry::ModelEntry {
2693                model: "api/current".into(),
2694                ..Default::default()
2695            },
2696        );
2697        crate::model_registry::set_provider_config(current);
2698
2699        let (_dir, hub) = temp_hub();
2700        let invalid = "[models.broken]\nmodel = \"api/broken\"\ncontext_budget = \"large\"\n";
2701        write_config(&hub, invalid);
2702        let before = std::fs::read(hub.config_toml_path()).unwrap();
2703
2704        let error = hub.add_alias("smart", "current").unwrap_err();
2705
2706        assert!(error.to_string().contains("parse config.toml"));
2707        assert_eq!(std::fs::read(hub.config_toml_path()).unwrap(), before);
2708        assert!(crate::model_registry::model_entry("current").is_some());
2709        assert!(crate::model_registry::model_entry("broken").is_none());
2710        crate::model_registry::set_provider_config(Default::default());
2711    }
2712
2713    #[test]
2714    fn theme_preference_defaults_to_auto_when_config_is_missing() {
2715        let (_dir, hub) = temp_hub();
2716
2717        assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
2718    }
2719
2720    #[test]
2721    fn theme_preference_defaults_to_auto_when_mode_is_missing() {
2722        let (_dir, hub) = temp_hub();
2723        write_config(&hub, "[theme]\n");
2724
2725        assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
2726    }
2727
2728    #[test]
2729    fn theme_preference_parses_supported_modes() {
2730        for (mode, expected) in [
2731            ("auto", ThemePreference::Auto),
2732            ("light", ThemePreference::Light),
2733            ("LiGhT", ThemePreference::Light),
2734            ("dark", ThemePreference::Dark),
2735        ] {
2736            let (_dir, hub) = temp_hub();
2737            write_config(&hub, &format!("[theme]\nmode = {mode:?}\n"));
2738
2739            assert_eq!(hub.theme_preference().unwrap(), expected);
2740        }
2741    }
2742
2743    #[test]
2744    fn theme_preference_rejects_unknown_mode() {
2745        let (_dir, hub) = temp_hub();
2746        write_config(&hub, "[theme]\nmode = \"sepia\"\n");
2747
2748        assert!(matches!(
2749            hub.theme_preference(),
2750            Err(ConfigError::Invalid(message)) if message.contains("theme.mode")
2751        ));
2752    }
2753
2754    fn auth_provider(id: &str) -> crate::auth_store::StoredProvider {
2755        crate::auth_store::StoredProvider {
2756            id: id.into(),
2757            name: id.into(),
2758            kind: crate::auth_store::ProviderKind::Codex,
2759            access_token: "old-access".into(),
2760            refresh_token: Some("old-refresh".into()),
2761            expires_at: 1,
2762            account: Some("old-account".into()),
2763            enabled: true,
2764            model_cache: None,
2765        }
2766    }
2767
2768    fn catalog_snapshot(
2769        hub: &ConfigHub,
2770        id: &str,
2771    ) -> crate::auth_store::AuthProviderCatalogSnapshot {
2772        hub.load_or_create_auth_provider_catalog_state(id)
2773            .unwrap()
2774            .unwrap()
2775            .1
2776    }
2777
2778    fn credential_snapshot(
2779        hub: &ConfigHub,
2780        id: &str,
2781    ) -> crate::auth_store::AuthProviderCredentialSnapshot {
2782        hub.load_or_create_auth_provider_credential_state(id)
2783            .unwrap()
2784            .unwrap()
2785            .1
2786    }
2787
2788    fn token_update(access_token: &str, refresh_token: &str) -> AuthTokenUpdate {
2789        AuthTokenUpdate {
2790            access_token: access_token.into(),
2791            refresh_token: Some(refresh_token.into()),
2792            expires_at: 99,
2793            account: Some("account@example.com".into()),
2794        }
2795    }
2796
2797    #[test]
2798    fn auth_transactions_preserve_independent_concurrent_updates() {
2799        let dir = tempfile::tempdir().unwrap();
2800        let path = dir.path().join("auth.json");
2801        let hub = ConfigHub::from_auth_path(&path);
2802        hub.add_auth_provider(auth_provider("provider")).unwrap();
2803
2804        let cache_hub = hub.clone();
2805        let cache = std::thread::spawn(move || {
2806            cache_hub
2807                .update_auth_model_cache_details(
2808                    "provider",
2809                    "stable-provider",
2810                    10,
2811                    &[crate::provider::DiscoveredModelDetails {
2812                        slug: "cached-model".into(),
2813                        context_budget: Some(8192),
2814                        capability_knowledge: crate::provider::CapabilityKnowledge::Advertised(
2815                            crate::provider::ModelCapabilities::default(),
2816                        ),
2817                    }],
2818                )
2819                .unwrap();
2820        });
2821        let token_hub = hub.clone();
2822        let tokens = std::thread::spawn(move || {
2823            token_hub
2824                .update_auth_tokens(
2825                    "provider",
2826                    AuthTokenUpdate {
2827                        access_token: "new-access".into(),
2828                        refresh_token: Some("new-refresh".into()),
2829                        expires_at: 99,
2830                        account: None,
2831                    },
2832                )
2833                .unwrap();
2834        });
2835        let enabled_hub = hub.clone();
2836        let enabled = std::thread::spawn(move || {
2837            enabled_hub
2838                .set_auth_provider_enabled("provider", false)
2839                .unwrap();
2840        });
2841        cache.join().unwrap();
2842        tokens.join().unwrap();
2843        enabled.join().unwrap();
2844
2845        let store = hub.load_auth().unwrap();
2846        let provider = &store.providers[0];
2847        assert_eq!(provider.access_token, "new-access");
2848        assert_eq!(provider.refresh_token.as_deref(), Some("new-refresh"));
2849        assert_eq!(provider.expires_at, 99);
2850        assert_eq!(provider.account.as_deref(), Some("old-account"));
2851        assert!(!provider.enabled);
2852        assert_eq!(
2853            provider.model_cache.as_ref().unwrap().models[0].slug,
2854            "cached-model"
2855        );
2856        assert!(matches!(
2857            hub.load_auth_model_cache_details("provider")
2858                .unwrap()
2859                .unwrap()[0]
2860                .capability_knowledge,
2861            crate::provider::CapabilityKnowledge::Advertised(_)
2862        ));
2863        assert_eq!(
2864            hub.load_auth_model_namespace("provider")
2865                .unwrap()
2866                .as_deref(),
2867            Some("stable-provider")
2868        );
2869        #[cfg(unix)]
2870        {
2871            use std::os::unix::fs::PermissionsExt;
2872            assert_eq!(
2873                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
2874                0o600
2875            );
2876        }
2877        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
2878            let name = entry.unwrap().file_name();
2879            let name = name.to_string_lossy();
2880            name.starts_with(".auth.json.") && name.ends_with(".tmp")
2881        }));
2882    }
2883
2884    #[test]
2885    fn auth_transaction_waits_for_external_file_lock() {
2886        use fs2::FileExt;
2887        use std::sync::mpsc::TryRecvError;
2888
2889        let dir = tempfile::tempdir().unwrap();
2890        let path = dir.path().join("auth.json");
2891        let lock_path = dir.path().join(".auth.json.lock");
2892        let lock = std::fs::OpenOptions::new()
2893            .read(true)
2894            .write(true)
2895            .create(true)
2896            .truncate(false)
2897            .open(lock_path)
2898            .unwrap();
2899        lock.lock_exclusive().unwrap();
2900
2901        let hub = ConfigHub::from_auth_path(&path);
2902        let (tx, rx) = std::sync::mpsc::channel();
2903        let worker = std::thread::spawn(move || {
2904            hub.add_auth_provider(auth_provider("blocked")).unwrap();
2905            tx.send(()).unwrap();
2906        });
2907        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
2908        FileExt::unlock(&lock).unwrap();
2909        rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
2910        worker.join().unwrap();
2911    }
2912
2913    #[test]
2914    fn auth_transaction_error_rolls_back_without_writing() {
2915        let dir = tempfile::tempdir().unwrap();
2916        let path = dir.path().join("auth.json");
2917        let hub = ConfigHub::from_auth_path(&path);
2918        hub.add_auth_provider(auth_provider("original")).unwrap();
2919        let before = std::fs::read(&path).unwrap();
2920
2921        let result: Result<(), ConfigError> = hub.update_auth(|store| {
2922            store.providers.push(auth_provider("discarded"));
2923            Err(ConfigError::Invalid("reject mutation".into()))
2924        });
2925        assert!(result.is_err());
2926        assert_eq!(std::fs::read(&path).unwrap(), before);
2927    }
2928
2929    #[test]
2930    fn auth_provider_ids_are_unique_and_duplicate_adds_do_not_write() {
2931        let dir = tempfile::tempdir().unwrap();
2932        let path = dir.path().join("auth.json");
2933        let hub = ConfigHub::from_auth_path(&path);
2934        hub.add_auth_provider(auth_provider("stable-id")).unwrap();
2935        let before = std::fs::read(&path).unwrap();
2936
2937        let error = hub
2938            .add_auth_provider(auth_provider("stable-id"))
2939            .unwrap_err();
2940
2941        assert!(error.to_string().contains("already exists"));
2942        assert_eq!(std::fs::read(&path).unwrap(), before);
2943        assert_eq!(hub.load_auth().unwrap().providers.len(), 1);
2944    }
2945
2946    #[test]
2947    fn auth_provider_and_typed_model_cache_are_added_in_one_transaction() {
2948        let (_dir, hub) = temp_hub();
2949        let models = [crate::provider::DiscoveredModelDetails {
2950            slug: "gpt-test".into(),
2951            context_budget: Some(32_000),
2952            capability_knowledge: crate::provider::CapabilityKnowledge::Advertised(
2953                crate::provider::ModelCapabilities::default(),
2954            ),
2955        }];
2956
2957        hub.add_auth_provider_with_model_cache_details(
2958            auth_provider("provider"),
2959            "provider@account",
2960            42,
2961            &models,
2962        )
2963        .unwrap();
2964
2965        let stored = hub.load_auth().unwrap().providers.remove(0);
2966        assert_eq!(stored.model_cache.unwrap().fetched_at, 42);
2967        assert_eq!(
2968            hub.load_auth_model_namespace("provider")
2969                .unwrap()
2970                .as_deref(),
2971            Some("provider@account")
2972        );
2973        assert_eq!(
2974            hub.load_auth_model_cache_details("provider").unwrap(),
2975            Some(models.to_vec())
2976        );
2977    }
2978
2979    #[test]
2980    fn conditional_auth_cache_update_rejects_stale_provider_state() {
2981        let (_dir, hub) = temp_hub();
2982        let initial = [crate::provider::DiscoveredModelDetails {
2983            slug: "initial".into(),
2984            context_budget: Some(8_192),
2985            capability_knowledge: crate::provider::CapabilityKnowledge::Legacy { thinking: false },
2986        }];
2987        hub.add_auth_provider_with_model_cache_details(
2988            auth_provider("provider"),
2989            "provider@account",
2990            1,
2991            &initial,
2992        )
2993        .unwrap();
2994        let catalog_snapshot = catalog_snapshot(&hub, "provider");
2995        hub.set_auth_provider_enabled("provider", false).unwrap();
2996        let before = std::fs::read(hub.auth_path.clone()).unwrap();
2997
2998        let replacement = [crate::provider::DiscoveredModelDetails {
2999            slug: "replacement".into(),
3000            context_budget: Some(16_384),
3001            capability_knowledge: crate::provider::CapabilityKnowledge::Legacy { thinking: true },
3002        }];
3003        assert_eq!(
3004            hub.update_auth_model_cache_details_if_enabled(
3005                &auth_provider("provider"),
3006                &catalog_snapshot,
3007                "provider@account",
3008                2,
3009                &replacement,
3010            )
3011            .unwrap(),
3012            AuthModelCacheCommit::Disabled
3013        );
3014        assert_eq!(std::fs::read(hub.auth_path.clone()).unwrap(), before);
3015        assert_eq!(
3016            hub.load_auth_model_cache_details("provider").unwrap(),
3017            Some(initial.to_vec())
3018        );
3019    }
3020
3021    #[test]
3022    fn catalog_revision_tracks_catalog_changes_but_not_credential_rotation() {
3023        let (_dir, hub) = temp_hub();
3024        hub.add_auth_provider(auth_provider("provider")).unwrap();
3025        let initial = catalog_snapshot(&hub, "provider");
3026
3027        assert!(
3028            hub.update_auth_tokens(
3029                "provider",
3030                AuthTokenUpdate {
3031                    access_token: "rotated-access".into(),
3032                    refresh_token: Some("rotated-refresh".into()),
3033                    expires_at: 123,
3034                    account: Some("rotated@example.com".into()),
3035                },
3036            )
3037            .unwrap()
3038        );
3039        assert_eq!(catalog_snapshot(&hub, "provider"), initial);
3040
3041        hub.set_auth_provider_enabled("provider", false).unwrap();
3042        let disabled = catalog_snapshot(&hub, "provider");
3043        assert_ne!(disabled, initial);
3044        hub.set_auth_provider_enabled("provider", true).unwrap();
3045        let enabled_again = catalog_snapshot(&hub, "provider");
3046        assert_ne!(enabled_again, disabled);
3047        assert_ne!(enabled_again, initial);
3048    }
3049
3050    #[test]
3051    fn catalog_cache_update_does_not_invalidate_credential_snapshot() {
3052        let (_dir, hub) = temp_hub();
3053        hub.add_auth_provider(auth_provider("provider")).unwrap();
3054        let expected = credential_snapshot(&hub, "provider");
3055
3056        assert!(
3057            hub.update_auth_model_cache_details(
3058                "provider",
3059                "provider@account",
3060                10,
3061                &[crate::provider::DiscoveredModelDetails {
3062                    slug: "cached-model".into(),
3063                    context_budget: Some(16_384),
3064                    capability_knowledge: crate::provider::CapabilityKnowledge::Advertised(
3065                        crate::provider::ModelCapabilities::default(),
3066                    ),
3067                }],
3068            )
3069            .unwrap()
3070        );
3071        assert_eq!(credential_snapshot(&hub, "provider"), expected);
3072
3073        match hub
3074            .update_auth_tokens_if_current(
3075                "provider",
3076                &expected,
3077                token_update("fresh-access", "fresh-refresh"),
3078            )
3079            .unwrap()
3080        {
3081            crate::auth_store::AuthCredentialCommit::Updated { provider, .. } => {
3082                assert_eq!(provider.access_token, "fresh-access");
3083                assert_eq!(provider.refresh_token.as_deref(), Some("fresh-refresh"));
3084                assert_eq!(
3085                    provider.model_cache.as_ref().unwrap().models[0].slug,
3086                    "cached-model"
3087                );
3088            }
3089            other => panic!("expected updated credential commit, got {other:?}"),
3090        }
3091    }
3092
3093    #[test]
3094    fn credential_snapshot_rejects_token_aba() {
3095        let (_dir, hub) = temp_hub();
3096        hub.add_auth_provider(auth_provider("provider")).unwrap();
3097        let original = credential_snapshot(&hub, "provider");
3098
3099        assert!(
3100            hub.update_auth_tokens(
3101                "provider",
3102                token_update("intermediate-access", "intermediate-refresh"),
3103            )
3104            .unwrap()
3105        );
3106        assert!(
3107            hub.update_auth_tokens(
3108                "provider",
3109                AuthTokenUpdate {
3110                    access_token: "old-access".into(),
3111                    refresh_token: Some("old-refresh".into()),
3112                    expires_at: 1,
3113                    account: Some("old-account".into()),
3114                },
3115            )
3116            .unwrap()
3117        );
3118        assert_ne!(credential_snapshot(&hub, "provider"), original);
3119        let before = std::fs::read(hub.auth_path()).unwrap();
3120
3121        assert!(matches!(
3122            hub.update_auth_tokens_if_current(
3123                "provider",
3124                &original,
3125                token_update("stale-access", "stale-refresh"),
3126            )
3127            .unwrap(),
3128            crate::auth_store::AuthCredentialCommit::Changed
3129        ));
3130        assert_eq!(std::fs::read(hub.auth_path()).unwrap(), before);
3131    }
3132
3133    #[test]
3134    fn provider_toggle_aba_preserves_credential_snapshot_but_replacement_invalidates_it() {
3135        let (_dir, hub) = temp_hub();
3136        hub.add_auth_provider(auth_provider("provider")).unwrap();
3137        let before_toggle = credential_snapshot(&hub, "provider");
3138
3139        assert!(hub.set_auth_provider_enabled("provider", false).unwrap());
3140        assert!(hub.set_auth_provider_enabled("provider", true).unwrap());
3141        assert_eq!(credential_snapshot(&hub, "provider"), before_toggle);
3142        let commit = hub
3143            .update_auth_tokens_if_current(
3144                "provider",
3145                &before_toggle,
3146                token_update("fresh-toggle-access", "fresh-toggle-refresh"),
3147            )
3148            .unwrap();
3149        assert!(matches!(
3150            commit,
3151            crate::auth_store::AuthCredentialCommit::Updated { .. }
3152        ));
3153
3154        let before_replacement = credential_snapshot(&hub, "provider");
3155        assert!(hub.remove_auth_provider("provider").unwrap());
3156        hub.add_auth_provider(auth_provider("provider")).unwrap();
3157        assert_ne!(credential_snapshot(&hub, "provider"), before_replacement);
3158        assert!(matches!(
3159            hub.update_auth_tokens_if_current(
3160                "provider",
3161                &before_replacement,
3162                token_update("stale-replacement-access", "stale-replacement-refresh"),
3163            )
3164            .unwrap(),
3165            crate::auth_store::AuthCredentialCommit::Changed
3166        ));
3167    }
3168
3169    #[test]
3170    fn credential_commit_persists_rotation_while_disabled_and_reports_missing_provider() {
3171        let (_dir, hub) = temp_hub();
3172        hub.add_auth_provider(auth_provider("provider")).unwrap();
3173        let expected = credential_snapshot(&hub, "provider");
3174
3175        assert!(hub.set_auth_provider_enabled("provider", false).unwrap());
3176        assert_eq!(credential_snapshot(&hub, "provider"), expected);
3177        match hub
3178            .update_auth_tokens_if_current(
3179                "provider",
3180                &expected,
3181                token_update("disabled-access", "disabled-refresh"),
3182            )
3183            .unwrap()
3184        {
3185            crate::auth_store::AuthCredentialCommit::Updated { provider } => {
3186                assert!(!provider.enabled);
3187                assert_eq!(provider.access_token, "disabled-access");
3188                assert_eq!(provider.refresh_token.as_deref(), Some("disabled-refresh"));
3189            }
3190            other => panic!("expected disabled credential rotation to persist, got {other:?}"),
3191        }
3192        let persisted = hub.load_auth().unwrap().providers.remove(0);
3193        assert!(!persisted.enabled);
3194        assert_eq!(persisted.access_token, "disabled-access");
3195        assert_eq!(persisted.refresh_token.as_deref(), Some("disabled-refresh"));
3196
3197        assert!(hub.remove_auth_provider("provider").unwrap());
3198        let before_missing = std::fs::read(hub.auth_path()).unwrap();
3199        assert!(matches!(
3200            hub.update_auth_tokens_if_current(
3201                "provider",
3202                &expected,
3203                token_update("missing-access", "missing-refresh"),
3204            )
3205            .unwrap(),
3206            crate::auth_store::AuthCredentialCommit::Missing
3207        ));
3208        assert_eq!(std::fs::read(hub.auth_path()).unwrap(), before_missing);
3209    }
3210
3211    #[test]
3212    fn legacy_credential_revision_is_lazily_persisted_without_changing_public_auth_shape() {
3213        let (_dir, hub) = temp_hub();
3214        std::fs::write(
3215            hub.auth_path(),
3216            r#"{
3217                "providers": [{
3218                    "id": "legacy",
3219                    "name": "Legacy",
3220                    "kind": "custom",
3221                    "access_token": "access",
3222                    "refresh_token": "refresh",
3223                    "expires_at": 1,
3224                    "account": "account@example.com",
3225                    "enabled": true
3226                }]
3227            }"#,
3228        )
3229        .unwrap();
3230        let legacy = std::fs::read(hub.auth_path()).unwrap();
3231
3232        let public_before = hub.load_auth().unwrap();
3233        assert_eq!(std::fs::read(hub.auth_path()).unwrap(), legacy);
3234        assert_eq!(public_before.providers[0].access_token, "access");
3235
3236        let first = credential_snapshot(&hub, "legacy");
3237        let migrated = std::fs::read(hub.auth_path()).unwrap();
3238        assert_ne!(migrated, legacy);
3239        assert!(
3240            serde_json::from_slice::<serde_json::Value>(&migrated).unwrap()["providers"][0]
3241                .get("credential_revision")
3242                .is_some()
3243        );
3244
3245        let peer = ConfigHub::from_auth_path(hub.auth_path());
3246        assert_eq!(credential_snapshot(&peer, "legacy"), first);
3247        assert_eq!(std::fs::read(hub.auth_path()).unwrap(), migrated);
3248
3249        let public_after = peer.load_auth().unwrap();
3250        assert_eq!(public_after.providers[0].id, "legacy");
3251        assert_eq!(public_after.providers[0].access_token, "access");
3252        let public_json = serde_json::to_value(public_after).unwrap();
3253        assert!(
3254            public_json["providers"][0]
3255                .get("credential_revision")
3256                .is_none()
3257        );
3258        let parsed_legacy_view: crate::auth_store::AuthStore =
3259            serde_json::from_slice(&migrated).unwrap();
3260        assert_eq!(parsed_legacy_view.providers[0].id, "legacy");
3261    }
3262
3263    #[test]
3264    fn legacy_catalog_revision_is_persisted_once_and_shared_by_hubs() {
3265        let (_dir, hub) = temp_hub();
3266        std::fs::write(
3267            hub.auth_path(),
3268            r#"{
3269                "providers": [{
3270                    "id": "legacy",
3271                    "name": "Legacy",
3272                    "kind": "codex",
3273                    "access_token": "access",
3274                    "expires_at": 1,
3275                    "enabled": true
3276                }]
3277            }"#,
3278        )
3279        .unwrap();
3280        let before = std::fs::read(hub.auth_path()).unwrap();
3281
3282        let first = catalog_snapshot(&hub, "legacy");
3283        let migrated = std::fs::read(hub.auth_path()).unwrap();
3284        assert_ne!(migrated, before);
3285        let peer = ConfigHub::from_auth_path(hub.auth_path());
3286        assert_eq!(catalog_snapshot(&peer, "legacy"), first);
3287        assert_eq!(std::fs::read(hub.auth_path()).unwrap(), migrated);
3288    }
3289
3290    #[test]
3291    fn catalog_revision_advances_for_equal_cache_commits_but_not_equal_enable_writes() {
3292        let (_dir, hub) = temp_hub();
3293        let models = [crate::provider::DiscoveredModelDetails {
3294            slug: "same".into(),
3295            context_budget: Some(8_192),
3296            capability_knowledge: crate::provider::CapabilityKnowledge::Legacy { thinking: false },
3297        }];
3298        hub.add_auth_provider_with_model_cache_details(
3299            auth_provider("provider"),
3300            "provider@account",
3301            1,
3302            &models,
3303        )
3304        .unwrap();
3305        let initial = catalog_snapshot(&hub, "provider");
3306
3307        assert!(
3308            hub.update_auth_model_cache_details("provider", "provider@account", 1, &models,)
3309                .unwrap()
3310        );
3311        let refreshed = catalog_snapshot(&hub, "provider");
3312        assert_ne!(refreshed, initial);
3313        let before_equal_enable = std::fs::read(hub.auth_path()).unwrap();
3314        assert!(hub.set_auth_provider_enabled("provider", true).unwrap());
3315        assert_eq!(catalog_snapshot(&hub, "provider"), refreshed);
3316        assert_eq!(std::fs::read(hub.auth_path()).unwrap(), before_equal_enable);
3317    }
3318
3319    #[test]
3320    fn removing_and_readding_the_same_provider_invalidates_old_catalog_snapshot() {
3321        let (_dir, hub) = temp_hub();
3322        let models = [crate::provider::DiscoveredModelDetails {
3323            slug: "same".into(),
3324            context_budget: Some(8_192),
3325            capability_knowledge: crate::provider::CapabilityKnowledge::Legacy { thinking: false },
3326        }];
3327        hub.add_auth_provider_with_model_cache_details(
3328            auth_provider("provider"),
3329            "provider@account",
3330            1,
3331            &models,
3332        )
3333        .unwrap();
3334        let original = catalog_snapshot(&hub, "provider");
3335        assert!(hub.remove_auth_provider("provider").unwrap());
3336        hub.add_auth_provider_with_model_cache_details(
3337            auth_provider("provider"),
3338            "provider@account",
3339            1,
3340            &models,
3341        )
3342        .unwrap();
3343        assert_ne!(catalog_snapshot(&hub, "provider"), original);
3344        let before = std::fs::read(hub.auth_path()).unwrap();
3345
3346        assert_eq!(
3347            hub.update_auth_model_cache_details_if_enabled(
3348                &auth_provider("provider"),
3349                &original,
3350                "provider@account",
3351                2,
3352                &models,
3353            )
3354            .unwrap(),
3355            AuthModelCacheCommit::Changed
3356        );
3357        assert_eq!(std::fs::read(hub.auth_path()).unwrap(), before);
3358    }
3359
3360    #[test]
3361    fn cache_write_and_follow_up_complete_before_a_peer_auth_write() {
3362        let (_dir, hub) = temp_hub();
3363        let initial = [crate::provider::DiscoveredModelDetails {
3364            slug: "initial".into(),
3365            context_budget: Some(8_192),
3366            capability_knowledge: crate::provider::CapabilityKnowledge::Legacy { thinking: false },
3367        }];
3368        hub.add_auth_provider_with_model_cache_details(
3369            auth_provider("provider"),
3370            "provider@account",
3371            1,
3372            &initial,
3373        )
3374        .unwrap();
3375        let expected = hub.load_auth().unwrap().providers.remove(0);
3376        let expected_catalog = catalog_snapshot(&hub, "provider");
3377        let replacement = vec![crate::provider::DiscoveredModelDetails {
3378            slug: "replacement".into(),
3379            context_budget: Some(16_384),
3380            capability_knowledge: crate::provider::CapabilityKnowledge::Legacy { thinking: true },
3381        }];
3382        let (follow_up_entered_tx, follow_up_entered_rx) = std::sync::mpsc::channel();
3383        let (release_follow_up_tx, release_follow_up_rx) = std::sync::mpsc::channel();
3384        let transaction_hub = hub.clone();
3385        let transaction = std::thread::spawn(move || {
3386            transaction_hub.update_auth_model_cache_details_if_enabled_and_then(
3387                AuthModelCacheUpdate {
3388                    expected: &expected,
3389                    expected_catalog: &expected_catalog,
3390                    expected_provider_ids: None,
3391                    model_namespace: "provider@account",
3392                    fetched_at: 2,
3393                    models: &replacement,
3394                },
3395                || {
3396                    assert_eq!(
3397                        transaction_hub
3398                            .load_auth_model_cache_details("provider")
3399                            .unwrap()
3400                            .unwrap()[0]
3401                            .slug,
3402                        "replacement"
3403                    );
3404                    follow_up_entered_tx.send(()).unwrap();
3405                    release_follow_up_rx.recv().unwrap();
3406                    "catalog-committed"
3407                },
3408            )
3409        });
3410        follow_up_entered_rx.recv().unwrap();
3411
3412        let (peer_started_tx, peer_started_rx) = std::sync::mpsc::channel();
3413        let (peer_done_tx, peer_done_rx) = std::sync::mpsc::channel();
3414        let peer_hub = hub.clone();
3415        let peer = std::thread::spawn(move || {
3416            peer_started_tx.send(()).unwrap();
3417            peer_hub
3418                .update_auth_model_cache_details(
3419                    "provider",
3420                    "provider@account",
3421                    3,
3422                    &[crate::provider::DiscoveredModelDetails {
3423                        slug: "peer".into(),
3424                        context_budget: Some(32_768),
3425                        capability_knowledge: crate::provider::CapabilityKnowledge::Legacy {
3426                            thinking: false,
3427                        },
3428                    }],
3429                )
3430                .unwrap();
3431            peer_done_tx.send(()).unwrap();
3432        });
3433        peer_started_rx.recv().unwrap();
3434        assert!(
3435            peer_done_rx
3436                .recv_timeout(std::time::Duration::from_millis(50))
3437                .is_err()
3438        );
3439
3440        release_follow_up_tx.send(()).unwrap();
3441        assert_eq!(
3442            transaction.join().unwrap().unwrap(),
3443            (AuthModelCacheCommit::Updated, Some("catalog-committed"))
3444        );
3445        peer_done_rx.recv().unwrap();
3446        peer.join().unwrap();
3447        assert_eq!(
3448            hub.load_auth_model_cache_details("provider")
3449                .unwrap()
3450                .unwrap()[0]
3451                .slug,
3452            "peer"
3453        );
3454    }
3455
3456    #[test]
3457    fn assigning_a_model_namespace_does_not_refresh_an_existing_cache() {
3458        let dir = tempfile::tempdir().unwrap();
3459        let path = dir.path().join("auth.json");
3460        let hub = ConfigHub::from_auth_path(&path);
3461        hub.add_auth_provider(auth_provider("provider")).unwrap();
3462        assert!(
3463            hub.update_auth_model_cache(
3464                "provider",
3465                crate::auth_store::ModelCache {
3466                    fetched_at: 7,
3467                    models: vec![],
3468                },
3469            )
3470            .unwrap()
3471        );
3472
3473        hub.ensure_auth_model_namespace("provider", "stable-provider")
3474            .unwrap();
3475
3476        let provider = hub.load_auth().unwrap().providers.remove(0);
3477        assert_eq!(provider.model_cache.unwrap().fetched_at, 7);
3478        assert_eq!(
3479            hub.load_auth_model_namespace("provider")
3480                .unwrap()
3481                .as_deref(),
3482            Some("stable-provider")
3483        );
3484        let before = std::fs::read(&path).unwrap();
3485        assert!(
3486            hub.ensure_auth_model_namespace("provider", "changed")
3487                .is_err()
3488        );
3489        assert_eq!(std::fs::read(&path).unwrap(), before);
3490    }
3491
3492    #[test]
3493    fn auth_transaction_does_not_overwrite_corrupt_json() {
3494        let dir = tempfile::tempdir().unwrap();
3495        let path = dir.path().join("auth.json");
3496        let corrupt = b"{not-json";
3497        std::fs::write(&path, corrupt).unwrap();
3498        let hub = ConfigHub::from_auth_path(&path);
3499
3500        let err = hub.add_auth_provider(auth_provider("new")).unwrap_err();
3501        assert!(err.to_string().contains("parse"));
3502        assert_eq!(std::fs::read(&path).unwrap(), corrupt);
3503    }
3504
3505    #[test]
3506    fn auth_load_defaults_when_file_is_missing() {
3507        let dir = tempfile::tempdir().unwrap();
3508        let hub = ConfigHub::from_auth_path(dir.path().join("auth.json"));
3509        assert!(hub.load_auth().unwrap().providers.is_empty());
3510    }
3511
3512    #[test]
3513    fn daemon_config_initializes_reuses_and_rotates_atomically() {
3514        let dir = tempfile::tempdir().unwrap();
3515        let path = dir.path().join("daemon.toml");
3516        let hub = ConfigHub::from_daemon_config_path(&path);
3517
3518        let first = hub.load_or_init_daemon_config().unwrap();
3519        assert_eq!(first.auth_token.len(), 64);
3520        assert!(first.auth_token.chars().all(|c| c.is_ascii_hexdigit()));
3521        let second = hub.load_or_init_daemon_config().unwrap();
3522        assert_eq!(second, first);
3523        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
3524            let name = entry.unwrap().file_name();
3525            let name = name.to_string_lossy();
3526            name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
3527        }));
3528        #[cfg(unix)]
3529        {
3530            use std::os::unix::fs::PermissionsExt;
3531            assert_eq!(
3532                std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3533                0o600
3534            );
3535        }
3536
3537        let rotated = hub.rotate_daemon_config().unwrap();
3538        assert_ne!(rotated.auth_token, first.auth_token);
3539        assert_eq!(hub.load_or_init_daemon_config().unwrap(), rotated);
3540        assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
3541            let name = entry.unwrap().file_name();
3542            let name = name.to_string_lossy();
3543            name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
3544        }));
3545    }
3546
3547    #[test]
3548    fn daemon_config_waits_for_external_file_lock() {
3549        use std::sync::mpsc::TryRecvError;
3550
3551        let dir = tempfile::tempdir().unwrap();
3552        let path = dir.path().join("custom-daemon.toml");
3553        let lock = lock_file(&lock_path_for(&path)).unwrap();
3554        let hub = ConfigHub::from_daemon_config_path(&path);
3555        let (tx, rx) = std::sync::mpsc::channel();
3556        let worker = std::thread::spawn(move || {
3557            tx.send(hub.load_or_init_daemon_config()).unwrap();
3558        });
3559
3560        std::thread::sleep(std::time::Duration::from_millis(25));
3561        assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
3562        lock.unlock().unwrap();
3563        assert!(
3564            rx.recv_timeout(std::time::Duration::from_secs(1))
3565                .unwrap()
3566                .is_ok()
3567        );
3568        worker.join().unwrap();
3569    }
3570
3571    #[cfg(unix)]
3572    #[test]
3573    fn legacy_daemon_config_uses_custom_path_and_owner_only_permissions() {
3574        use std::os::unix::fs::PermissionsExt;
3575
3576        let config = tempfile::tempdir().unwrap();
3577        let data = tempfile::tempdir().unwrap();
3578        let daemon_path = config.path().join("daemon/custom.toml");
3579        std::fs::write(data.path().join("daemon.toml"), "auth_token = \"legacy\"\n").unwrap();
3580
3581        let report = ConfigHub::from_config_dir(config.path())
3582            .with_daemon_config_path(&daemon_path)
3583            .migrate_legacy_layout(data.path())
3584            .unwrap()
3585            .unwrap();
3586
3587        assert!(report.moved.iter().any(|path| path == "daemon.toml"));
3588        assert_eq!(
3589            std::fs::read_to_string(&daemon_path).unwrap(),
3590            "auth_token = \"legacy\"\n"
3591        );
3592        assert!(!config.path().join("daemon.toml").exists());
3593        assert_eq!(
3594            std::fs::metadata(&daemon_path)
3595                .unwrap()
3596                .permissions()
3597                .mode()
3598                & 0o777,
3599            0o600
3600        );
3601    }
3602
3603    #[test]
3604    fn daemon_config_rotation_requires_existing_file() {
3605        let dir = tempfile::tempdir().unwrap();
3606        let path = dir.path().join("missing.toml");
3607        let err = ConfigHub::from_daemon_config_path(&path)
3608            .rotate_daemon_config()
3609            .unwrap_err();
3610        assert!(err.to_string().contains("no daemon config"));
3611    }
3612
3613    #[test]
3614    fn web_configs_default_when_config_or_section_is_missing() {
3615        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
3616            let (_dir, hub) = temp_hub();
3617            if let Some(text) = text {
3618                write_config(&hub, text);
3619            }
3620
3621            let fetch = hub.web_fetch_config().unwrap();
3622            assert_eq!(fetch.max_bytes, 1_000_000);
3623            assert!(fetch.url_allowlist.is_empty());
3624            assert!(fetch.url_denylist.is_empty());
3625            let search = hub.web_search_config().unwrap();
3626            assert_eq!(search.provider_name(), "tavily");
3627        }
3628    }
3629
3630    #[test]
3631    fn web_configs_parse_fetch_and_search_fields() {
3632        let (_dir, hub) = temp_hub();
3633        write_config(
3634            &hub,
3635            r#"
3636[web]
3637max_bytes = 4096
3638url_allowlist = ["https://ok.example"]
3639url_denylist = ["https://ok.example/private"]
3640
3641[web.search]
3642provider = "searxng"
3643base_url = "http://localhost:8080"
3644max_results = 6
3645"#,
3646        );
3647
3648        let fetch = hub.web_fetch_config().unwrap();
3649        assert_eq!(fetch.max_bytes, 4096);
3650        assert_eq!(fetch.url_allowlist, vec!["https://ok.example"]);
3651        assert_eq!(fetch.url_denylist, vec!["https://ok.example/private"]);
3652        assert_eq!(hub.web_search_config().unwrap().provider_name(), "searxng");
3653    }
3654
3655    #[test]
3656    fn web_fetch_schema_error_does_not_break_valid_search() {
3657        let (_dir, hub) = temp_hub();
3658        write_config(
3659            &hub,
3660            "[web]\nmax_bytes = \"large\"\n[web.search]\nprovider = \"none\"\n",
3661        );
3662
3663        assert!(matches!(
3664            hub.web_fetch_config(),
3665            Err(ConfigError::Invalid(_))
3666        ));
3667        assert_eq!(hub.web_search_config().unwrap().provider_name(), "none");
3668    }
3669
3670    #[test]
3671    fn web_search_schema_error_does_not_break_valid_fetch() {
3672        let (_dir, hub) = temp_hub();
3673        write_config(
3674            &hub,
3675            "[web]\nmax_bytes = 2048\n[web.search]\nprovider = \"unknown\"\n",
3676        );
3677
3678        assert_eq!(hub.web_fetch_config().unwrap().max_bytes, 2048);
3679        assert!(matches!(
3680            hub.web_search_config(),
3681            Err(ConfigError::Invalid(_))
3682        ));
3683    }
3684
3685    #[test]
3686    fn trust_config_defaults_when_config_or_section_is_missing() {
3687        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
3688            let (_dir, hub) = temp_hub();
3689            if let Some(text) = text {
3690                write_config(&hub, text);
3691            }
3692
3693            let config = hub.trust_config().unwrap();
3694            assert_eq!(config.mode, crate::trust::TrustMode::Steady);
3695            assert_eq!(config.theme, crate::trust::Theme::Default);
3696            assert_eq!(config.escalation, crate::trust::EscalationPolicy::Ask);
3697        }
3698    }
3699
3700    #[test]
3701    fn trust_config_parses_mode_theme_and_escalation() {
3702        let (_dir, hub) = temp_hub();
3703        write_config(
3704            &hub,
3705            "[trust]\nmode = \"eager\"\ntheme = \"weather\"\nescalation = \"deny\"\n",
3706        );
3707
3708        let config = hub.trust_config().unwrap();
3709        assert_eq!(config.mode, crate::trust::TrustMode::Eager);
3710        assert_eq!(config.theme, crate::trust::Theme::Weather);
3711        assert_eq!(config.escalation, crate::trust::EscalationPolicy::Deny);
3712    }
3713
3714    #[test]
3715    fn trust_config_parses_new_policy() {
3716        use crate::tool::Tier;
3717        use crate::trust::{EscalationPolicy, PolicyAction, RiskKind};
3718
3719        let (_dir, hub) = temp_hub();
3720        write_config(
3721            &hub,
3722            "[trust]\nmode = \"eager\"\nescalation = \"allow\"\n\
3723             [trust.tiers.eager]\ntier4 = \"deny\"\n\
3724             [trust.risks.eager]\nnetwork = \"deny\"\nfilesystem_write = \"auto\"\noutside_workspace = \"auto\"\n",
3725        );
3726
3727        let config = hub.trust_config().unwrap();
3728        assert_eq!(config.escalation, EscalationPolicy::Allow);
3729        assert_eq!(config.resolve_tier(Tier::Four), PolicyAction::Deny);
3730        assert_eq!(config.resolve_risk(RiskKind::Network), PolicyAction::Deny);
3731        assert_eq!(
3732            config.resolve_risk(RiskKind::WorkspaceExternal),
3733            PolicyAction::Auto
3734        );
3735        assert_eq!(
3736            config.resolve_risk(RiskKind::FilesystemWrite),
3737            PolicyAction::Auto
3738        );
3739        assert_eq!(config.resolve_policy(Tier::Four, []), PolicyAction::Deny);
3740        assert_eq!(
3741            config.resolve_policy(Tier::Zero, [RiskKind::Network]),
3742            PolicyAction::Deny
3743        );
3744    }
3745
3746    #[test]
3747    fn config_hub_rejects_obsolete_trust_outside() {
3748        let (_dir, hub) = temp_hub();
3749        write_config(&hub, "[trust]\noutside = \"allow\"\n");
3750
3751        assert!(matches!(
3752            hub.trust_config(),
3753            Err(ConfigError::Invalid(message))
3754                if message.contains("parse trust config") && message.contains("outside")
3755        ));
3756    }
3757
3758    #[test]
3759    fn config_hub_rejects_obsolete_nested_trust_risk() {
3760        let (_dir, hub) = temp_hub();
3761        write_config(
3762            &hub,
3763            "[trust.risks.eager]\nsandbox_violation = \"deny\"\noutside_workspace = \"deny\"\n",
3764        );
3765
3766        assert!(matches!(
3767            hub.trust_config(),
3768            Err(ConfigError::Invalid(message))
3769                if message.contains("parse trust config")
3770                    && message.contains("sandbox_violation")
3771        ));
3772    }
3773
3774    #[test]
3775    fn trust_config_rejects_invalid_enum() {
3776        let (_dir, hub) = temp_hub();
3777        write_config(&hub, "[trust]\nescalation = \"sometimes\"\n");
3778
3779        assert!(matches!(
3780            hub.trust_config(),
3781            Err(ConfigError::Invalid(message)) if message.contains("parse trust config")
3782        ));
3783    }
3784
3785    #[test]
3786    fn preview_config_defaults_when_config_or_section_is_missing() {
3787        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
3788            let (_dir, hub) = temp_hub();
3789            if let Some(text) = text {
3790                write_config(&hub, text);
3791            }
3792
3793            let config = hub.preview_config().unwrap();
3794            let expected = crate::tools::preview::PreviewConfig::default();
3795            assert_eq!(config.base_url, expected.base_url);
3796            assert_eq!(config.timeout_ms, expected.timeout_ms);
3797            assert_eq!(config.project_abs_path, expected.project_abs_path);
3798            assert_eq!(config.project_hint_slug, expected.project_hint_slug);
3799            assert_eq!(config.max_body_bytes, expected.max_body_bytes);
3800        }
3801    }
3802
3803    #[test]
3804    fn preview_config_parses_all_supported_fields() {
3805        let (_dir, hub) = temp_hub();
3806        write_config(
3807            &hub,
3808            r#"
3809[preview]
3810base_url = "http://127.0.0.1:9000"
3811timeout_ms = 4500
3812project_abs_path = "/tmp/project"
3813project_hint_slug = "project"
3814max_body_bytes = 2048
3815"#,
3816        );
3817
3818        let config = hub.preview_config().unwrap();
3819        assert_eq!(config.base_url, "http://127.0.0.1:9000");
3820        assert_eq!(config.timeout_ms, 4500);
3821        assert_eq!(config.project_abs_path, "/tmp/project");
3822        assert_eq!(config.project_hint_slug.as_deref(), Some("project"));
3823        assert_eq!(config.max_body_bytes, 2048);
3824    }
3825
3826    #[test]
3827    fn preview_config_rejects_invalid_schema() {
3828        let (_dir, hub) = temp_hub();
3829        write_config(&hub, "[preview]\ntimeout_ms = \"slow\"\n");
3830
3831        assert!(matches!(
3832            hub.preview_config(),
3833            Err(ConfigError::Invalid(message)) if message.contains("parse preview config")
3834        ));
3835    }
3836
3837    #[test]
3838    fn sandbox_config_defaults_when_config_or_section_is_missing() {
3839        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
3840            let (_dir, hub) = temp_hub();
3841            if let Some(text) = text {
3842                write_config(&hub, text);
3843            }
3844
3845            assert_eq!(hub.sandbox_config().unwrap(), SandboxConfig::default());
3846        }
3847    }
3848
3849    #[test]
3850    fn sandbox_config_preserves_paths_and_defaults_missing_enabled() {
3851        let (_dir, hub) = temp_hub();
3852        write_config(
3853            &hub,
3854            r#"
3855[sandbox]
3856strict = true
3857extra_read = ["../read"]
3858extra_write = ["/tmp/write"]
3859template_path = "profiles/custom.sb"
3860allow_network = true
3861"#,
3862        );
3863
3864        assert_eq!(
3865            hub.sandbox_config().unwrap(),
3866            SandboxConfig {
3867                enabled: true,
3868                strict: true,
3869                extra_read: vec![PathBuf::from("../read")],
3870                extra_write: vec![PathBuf::from("/tmp/write")],
3871                template_path: Some(PathBuf::from("profiles/custom.sb")),
3872                allow_network: true,
3873            }
3874        );
3875    }
3876
3877    #[test]
3878    fn sandbox_config_allows_explicit_opt_out() {
3879        let (_dir, hub) = temp_hub();
3880        write_config(&hub, "[sandbox]\nenabled = false\n");
3881
3882        assert!(!hub.sandbox_config().unwrap().enabled);
3883    }
3884
3885    #[test]
3886    fn sandbox_config_rejects_invalid_schema() {
3887        let (_dir, hub) = temp_hub();
3888        write_config(&hub, "[sandbox]\nextra_read = \"/tmp\"\n");
3889
3890        assert!(matches!(
3891            hub.sandbox_config(),
3892            Err(ConfigError::Invalid(message)) if message.contains("parse sandbox config")
3893        ));
3894    }
3895
3896    #[test]
3897    fn redact_config_defaults_when_config_is_missing_or_section_is_missing() {
3898        for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
3899            let (_dir, hub) = temp_hub();
3900            if let Some(text) = text {
3901                write_config(&hub, text);
3902            }
3903
3904            assert_eq!(hub.redact_config().unwrap(), RedactConfig::default());
3905        }
3906    }
3907
3908    #[test]
3909    fn redact_config_parses_mode_patterns_and_allowlist() {
3910        let (_dir, hub) = temp_hub();
3911        write_config(
3912            &hub,
3913            r#"
3914[redact]
3915enabled = true
3916mode = "partial"
3917allowlist = ["safe@example.com"]
3918custom_patterns = [{ kind = "ticket", regex = "T-[0-9]+" }]
3919"#,
3920        );
3921
3922        assert_eq!(
3923            hub.redact_config().unwrap(),
3924            RedactConfig {
3925                enabled: true,
3926                partial: true,
3927                allowlist: vec!["safe@example.com".into()],
3928                custom_patterns: vec![("ticket".into(), "T-[0-9]+".into())],
3929            }
3930        );
3931    }
3932
3933    #[test]
3934    fn redact_config_treats_unknown_mode_as_full() {
3935        let (_dir, hub) = temp_hub();
3936        write_config(&hub, "[redact]\nenabled = true\nmode = \"unknown\"\n");
3937
3938        let config = hub.redact_config().unwrap();
3939        assert!(config.enabled);
3940        assert!(!config.partial);
3941    }
3942
3943    #[test]
3944    fn redact_config_rejects_invalid_schema() {
3945        let (_dir, hub) = temp_hub();
3946        write_config(&hub, "[redact]\nenabled = \"yes\"\n");
3947
3948        assert!(matches!(
3949            hub.redact_config(),
3950            Err(ConfigError::Invalid(message)) if message.contains("parse redact config")
3951        ));
3952    }
3953
3954    #[test]
3955    fn interjection_mode_defaults_to_none_when_config_or_value_is_missing() {
3956        for text in [
3957            None,
3958            Some("[theme]\nmode = \"dark\"\n"),
3959            Some("[interjection]\n"),
3960        ] {
3961            let (_dir, hub) = temp_hub();
3962            if let Some(text) = text {
3963                write_config(&hub, text);
3964            }
3965
3966            assert_eq!(hub.interjection_mode().unwrap(), None);
3967        }
3968    }
3969
3970    #[test]
3971    fn interjection_mode_parses_supported_and_unknown_values() {
3972        for (value, expected) in [
3973            ("off", InterjectionMode::Off),
3974            ("rule", InterjectionMode::Rule),
3975            ("llm", InterjectionMode::Llm),
3976            ("custom", InterjectionMode::Unknown("custom".into())),
3977        ] {
3978            let (_dir, hub) = temp_hub();
3979            write_config(&hub, &format!("[interjection]\nclassifier = {value:?}\n"));
3980
3981            assert_eq!(hub.interjection_mode().unwrap(), Some(expected));
3982        }
3983    }
3984
3985    #[test]
3986    fn interjection_mode_rejects_non_string_value() {
3987        let (_dir, hub) = temp_hub();
3988        write_config(&hub, "[interjection]\nclassifier = true\n");
3989
3990        assert!(matches!(
3991            hub.interjection_mode(),
3992            Err(ConfigError::Invalid(message)) if message.contains("interjection.classifier")
3993        ));
3994    }
3995
3996    #[test]
3997    fn suggest_model_defaults_to_none_when_config_or_value_is_missing() {
3998        for text in [
3999            None,
4000            Some("[theme]\nmode = \"dark\"\n"),
4001            Some("[suggest]\n"),
4002        ] {
4003            let (_dir, hub) = temp_hub();
4004            if let Some(text) = text {
4005                write_config(&hub, text);
4006            }
4007
4008            assert_eq!(hub.suggest_model().unwrap(), None);
4009        }
4010    }
4011
4012    #[test]
4013    fn suggest_model_returns_configured_string_including_empty() {
4014        for value in ["smart", ""] {
4015            let (_dir, hub) = temp_hub();
4016            write_config(&hub, &format!("[suggest]\nmodel = {value:?}\n"));
4017
4018            assert_eq!(hub.suggest_model().unwrap().as_deref(), Some(value));
4019        }
4020    }
4021
4022    #[test]
4023    fn suggest_model_rejects_non_string_value() {
4024        let (_dir, hub) = temp_hub();
4025        write_config(&hub, "[suggest]\nmodel = 42\n");
4026
4027        assert!(matches!(
4028            hub.suggest_model(),
4029            Err(ConfigError::Invalid(message)) if message.contains("suggest.model")
4030        ));
4031    }
4032
4033    #[test]
4034    fn compact_review_mode_defaults_to_none_when_config_or_value_is_missing() {
4035        for text in [
4036            None,
4037            Some("[theme]\nmode = \"dark\"\n"),
4038            Some("[compaction]\n"),
4039        ] {
4040            let (_dir, hub) = temp_hub();
4041            if let Some(text) = text {
4042                write_config(&hub, text);
4043            }
4044
4045            assert_eq!(hub.compact_review_mode().unwrap(), None);
4046        }
4047    }
4048
4049    #[test]
4050    fn compact_review_mode_parses_supported_values() {
4051        for (value, expected) in [
4052            ("always", crate::CompactReviewMode::Always),
4053            ("manual-only", crate::CompactReviewMode::ManualOnly),
4054            ("manual_only", crate::CompactReviewMode::ManualOnly),
4055            ("never", crate::CompactReviewMode::Never),
4056        ] {
4057            let (_dir, hub) = temp_hub();
4058            write_config(&hub, &format!("[compaction]\nreview = {value:?}\n"));
4059
4060            assert_eq!(hub.compact_review_mode().unwrap(), Some(expected));
4061        }
4062    }
4063
4064    #[test]
4065    fn compact_review_mode_rejects_unknown_or_non_string_value() {
4066        for value in ["\"sometimes\"", "true"] {
4067            let (_dir, hub) = temp_hub();
4068            write_config(&hub, &format!("[compaction]\nreview = {value}\n"));
4069
4070            assert!(matches!(
4071                hub.compact_review_mode(),
4072                Err(ConfigError::Invalid(message)) if message.contains("compaction.review")
4073            ));
4074        }
4075    }
4076
4077    #[test]
4078    fn auto_snapshot_defaults_to_none_when_config_or_value_is_missing() {
4079        for text in [
4080            None,
4081            Some("[theme]\nmode = \"dark\"\n"),
4082            Some("[registry]\n"),
4083        ] {
4084            let (_dir, hub) = temp_hub();
4085            if let Some(text) = text {
4086                write_config(&hub, text);
4087            }
4088
4089            assert_eq!(hub.auto_snapshot().unwrap(), None);
4090        }
4091    }
4092
4093    #[test]
4094    fn auto_snapshot_reads_boolean_values() {
4095        for value in [true, false] {
4096            let (_dir, hub) = temp_hub();
4097            write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
4098
4099            assert_eq!(hub.auto_snapshot().unwrap(), Some(value));
4100        }
4101    }
4102
4103    #[test]
4104    fn auto_snapshot_reads_integer_values() {
4105        for (value, expected) in [(1, true), (0, false)] {
4106            let (_dir, hub) = temp_hub();
4107            write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
4108
4109            assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
4110        }
4111    }
4112
4113    #[test]
4114    fn auto_snapshot_only_enables_exact_true_string() {
4115        for (value, expected) in [("true", true), ("yes", false)] {
4116            let (_dir, hub) = temp_hub();
4117            write_config(&hub, &format!("[registry]\nauto_snapshot = {value:?}\n"));
4118
4119            assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
4120        }
4121    }
4122
4123    #[test]
4124    fn auto_snapshot_rejects_unsupported_type() {
4125        let (_dir, hub) = temp_hub();
4126        write_config(&hub, "[registry]\nauto_snapshot = [true]\n");
4127
4128        assert!(matches!(
4129            hub.auto_snapshot(),
4130            Err(ConfigError::Invalid(message)) if message.contains("registry.auto_snapshot")
4131        ));
4132    }
4133
4134    #[test]
4135    fn fs_access_mode_defaults_to_none_when_config_is_missing() {
4136        let (_dir, hub) = temp_hub();
4137
4138        assert_eq!(hub.fs_access_mode().unwrap(), None);
4139    }
4140
4141    #[test]
4142    fn fs_access_mode_defaults_to_none_when_section_or_mode_is_missing() {
4143        for text in ["[theme]\nmode = \"dark\"\n", "[fs_access]\n"] {
4144            let (_dir, hub) = temp_hub();
4145            write_config(&hub, text);
4146
4147            assert_eq!(hub.fs_access_mode().unwrap(), None);
4148        }
4149    }
4150
4151    #[test]
4152    fn fs_access_mode_parses_canonical_and_alias_values() {
4153        for (mode, expected) in [
4154            ("read-only", crate::fs_access::FsAccessMode::ReadOnly),
4155            ("ws", crate::fs_access::FsAccessMode::WorkspaceWrite),
4156            (
4157                "danger-full-access",
4158                crate::fs_access::FsAccessMode::DangerFullAccess,
4159            ),
4160        ] {
4161            let (_dir, hub) = temp_hub();
4162            write_config(&hub, &format!("[fs_access]\nmode = {mode:?}\n"));
4163
4164            assert_eq!(hub.fs_access_mode().unwrap(), Some(expected));
4165        }
4166    }
4167
4168    #[test]
4169    fn fs_access_mode_rejects_unknown_mode() {
4170        let (_dir, hub) = temp_hub();
4171        write_config(&hub, "[fs_access]\nmode = \"chaos\"\n");
4172
4173        assert!(matches!(
4174            hub.fs_access_mode(),
4175            Err(ConfigError::Invalid(message)) if message.contains("unknown fs access mode")
4176        ));
4177    }
4178
4179    #[test]
4180    fn fs_access_mode_rejects_non_string_mode() {
4181        let (_dir, hub) = temp_hub();
4182        write_config(&hub, "[fs_access]\nmode = true\n");
4183
4184        assert!(matches!(
4185            hub.fs_access_mode(),
4186            Err(ConfigError::Invalid(message)) if message.contains("fs_access.mode")
4187        ));
4188    }
4189
4190    fn model<'a>(
4191        old_name: Option<&'a str>,
4192        name: &'a str,
4193        model: &'a str,
4194    ) -> ModelConfigUpdate<'a> {
4195        ModelConfigUpdate {
4196            old_name,
4197            name,
4198            model,
4199            provider: Some("test"),
4200            context_budget: 100_000,
4201            reasoning: crate::provider::ReasoningSelection::Disabled,
4202            capabilities: None,
4203            image_detail: None,
4204            max_tokens: None,
4205            enabled: true,
4206        }
4207    }
4208
4209    #[test]
4210    fn model_migration_preserves_existing_provider_name() {
4211        let (_dir, hub) = temp_hub();
4212        write_config(
4213            &hub,
4214            r#"[providers.openai]
4215kind = "openai"
4216api_key = "existing"
4217
4218[models.legacy]
4219model = "gpt"
4220provider = "openai"
4221api_key = "legacy"
4222"#,
4223        );
4224
4225        let outcome = hub.migrate_model_config_if_needed().unwrap();
4226        assert!(matches!(
4227            outcome,
4228            crate::model_registry::ModelMigrationOutcome::Migrated { .. }
4229        ));
4230        let text = hub.read_config_toml().unwrap();
4231        assert!(text.contains("[providers.openai]"));
4232        assert!(text.contains("api_key = \"existing\""));
4233        assert!(text.contains("[providers.openai-2]"));
4234        assert!(text.contains("provider = \"openai-2\""));
4235    }
4236
4237    #[test]
4238    fn model_migration_preserves_unversioned_provider_reference() {
4239        let (dir, hub) = temp_hub();
4240        let text = r#"[providers.openai]
4241kind = "openai"
4242api_key = "existing"
4243
4244[models.current]
4245model = "gpt"
4246provider = "openai"
4247"#;
4248        write_config(&hub, text);
4249
4250        assert_eq!(
4251            hub.migrate_model_config_if_needed().unwrap(),
4252            crate::model_registry::ModelMigrationOutcome::NotNeeded
4253        );
4254        assert_eq!(hub.read_config_toml().unwrap(), text);
4255        assert!(!dir.path().join("config.toml.bak").exists());
4256    }
4257
4258    #[test]
4259    fn model_migration_rejects_invalid_and_future_versions() {
4260        for version in ["\"2\"", "3"] {
4261            let (_dir, hub) = temp_hub();
4262            let text = format!(
4263                "config_version = {version}\n[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n"
4264            );
4265            write_config(&hub, &text);
4266            assert!(hub.migrate_model_config_if_needed().is_err());
4267            assert_eq!(hub.read_config_toml().unwrap(), text);
4268        }
4269    }
4270
4271    #[test]
4272    fn model_migration_backup_conflict_preserves_source() {
4273        let (dir, hub) = temp_hub();
4274        let text = "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n";
4275        write_config(&hub, text);
4276        std::fs::write(dir.path().join("config.toml.bak"), "older backup").unwrap();
4277
4278        assert!(matches!(
4279            hub.migrate_model_config_if_needed(),
4280            Err(ConfigError::Invalid(message)) if message.contains("backup conflict")
4281        ));
4282        assert_eq!(hub.read_config_toml().unwrap(), text);
4283    }
4284
4285    #[cfg(unix)]
4286    #[test]
4287    fn model_migration_backup_is_owner_only() {
4288        use std::os::unix::fs::PermissionsExt;
4289        let (dir, hub) = temp_hub();
4290        write_config(
4291            &hub,
4292            "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\napi_key = \"secret\"\n",
4293        );
4294
4295        hub.migrate_model_config_if_needed().unwrap();
4296
4297        let mode = std::fs::metadata(dir.path().join("config.toml.bak"))
4298            .unwrap()
4299            .permissions()
4300            .mode()
4301            & 0o777;
4302        assert_eq!(mode, 0o600);
4303    }
4304
4305    #[test]
4306    fn duplicate_model_name_is_rejected_without_writing() {
4307        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4308        let (_dir, hub) = temp_hub();
4309        hub.upsert_model(model(None, "shared", "provider/a"))
4310            .unwrap();
4311        let before = hub.read_config_toml().unwrap();
4312
4313        let error = hub
4314            .upsert_model(model(None, "shared", "provider/b"))
4315            .unwrap_err();
4316
4317        assert!(matches!(
4318            error,
4319            ConfigError::NameConflict {
4320                domain: "models",
4321                ..
4322            }
4323        ));
4324        assert_eq!(hub.read_config_toml().unwrap(), before);
4325    }
4326
4327    #[test]
4328    fn model_rename_conflict_is_rejected_without_removing_source() {
4329        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4330        let (_dir, hub) = temp_hub();
4331        hub.upsert_model(model(None, "first", "provider/a"))
4332            .unwrap();
4333        hub.upsert_model(model(None, "second", "provider/b"))
4334            .unwrap();
4335        let before = hub.read_config_toml().unwrap();
4336
4337        let error = hub
4338            .upsert_model(model(Some("first"), "second", "provider/a"))
4339            .unwrap_err();
4340
4341        assert!(matches!(error, ConfigError::NameConflict { .. }));
4342        assert_eq!(hub.read_config_toml().unwrap(), before);
4343    }
4344
4345    #[test]
4346    fn model_and_alias_share_a_namespace() {
4347        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4348        let (_dir, hub) = temp_hub();
4349        hub.upsert_model(model(None, "smart", "provider/a"))
4350            .unwrap();
4351        assert!(matches!(
4352            hub.add_alias("smart", "provider/a"),
4353            Err(ConfigError::NameConflict {
4354                domain: "models",
4355                ..
4356            })
4357        ));
4358
4359        hub.add_alias("cheap", "provider/a").unwrap();
4360        assert!(matches!(
4361            hub.upsert_model(model(None, "cheap", "provider/b")),
4362            Err(ConfigError::NameConflict {
4363                domain: "alias",
4364                ..
4365            })
4366        ));
4367    }
4368
4369    #[test]
4370    fn model_delete_rejects_alias_dependents_without_writing() {
4371        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4372        let (_dir, hub) = temp_hub();
4373        hub.upsert_model(model(None, "chat", "provider/chat"))
4374            .unwrap();
4375        hub.add_alias("fast", "chat").unwrap();
4376        let before = hub.read_config_toml().unwrap();
4377
4378        let error = hub.remove_model("chat").unwrap_err();
4379
4380        assert!(matches!(
4381            error,
4382            ConfigError::Invalid(message) if message.contains("referenced by aliases: fast")
4383        ));
4384        assert_eq!(hub.read_config_toml().unwrap(), before);
4385    }
4386
4387    #[test]
4388    fn provider_delete_rejects_model_dependents_without_writing() {
4389        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4390        let (_dir, hub) = temp_hub();
4391        write_config(
4392            &hub,
4393            "[providers.gateway]\nkind = \"openai-compatible\"\n\n[models.chat]\nmodel = \"chat\"\nprovider = \"gateway\"\n",
4394        );
4395        let before = hub.read_config_toml().unwrap();
4396
4397        let error = hub.remove_provider("gateway").unwrap_err();
4398
4399        assert!(matches!(
4400            error,
4401            ConfigError::Invalid(message) if message.contains("referenced by models: chat")
4402        ));
4403        assert_eq!(hub.read_config_toml().unwrap(), before);
4404    }
4405
4406    #[test]
4407    fn alias_rename_conflict_is_rejected_without_removing_source() {
4408        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4409        let (_dir, hub) = temp_hub();
4410        hub.add_alias("first", "provider/a").unwrap();
4411        hub.add_alias("second", "provider/b").unwrap();
4412        let before = hub.read_config_toml().unwrap();
4413
4414        let error = hub
4415            .update_alias(Some("first"), "second", "provider/a")
4416            .unwrap_err();
4417
4418        assert!(matches!(
4419            error,
4420            ConfigError::NameConflict {
4421                domain: "alias",
4422                ..
4423            }
4424        ));
4425        assert_eq!(hub.read_config_toml().unwrap(), before);
4426    }
4427
4428    #[test]
4429    fn bind_default_model_rebinds_smart_atomically_and_keeps_cheap() {
4430        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4431        let (_dir, hub) = temp_hub();
4432        hub.add_alias("smart", "provider/old").unwrap();
4433
4434        hub.bind_default_model("provider/new").unwrap();
4435
4436        let text = hub.read_config_toml().unwrap();
4437        assert!(text.contains("[alias.smart]"));
4438        assert!(text.contains("model = \"provider/new\""));
4439        assert!(text.contains("[alias.cheap]"));
4440        assert!(text.contains("model = \"smart\""));
4441    }
4442
4443    #[test]
4444    fn bind_default_model_preserves_existing_cheap_alias() {
4445        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4446        let (_dir, hub) = temp_hub();
4447        hub.add_alias("smart", "provider/old").unwrap();
4448        hub.add_alias("cheap", "provider/custom-cheap").unwrap();
4449
4450        hub.bind_default_model("provider/new").unwrap();
4451
4452        let cfg = crate::model_registry::parse_config(&hub.read_config_toml().unwrap()).unwrap();
4453        assert_eq!(cfg.aliases["smart"].model, "provider/new");
4454        assert_eq!(cfg.aliases["cheap"].model, "provider/custom-cheap");
4455    }
4456
4457    #[test]
4458    fn bind_default_model_does_not_overwrite_smart_model() {
4459        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4460        let (_dir, hub) = temp_hub();
4461        hub.upsert_model(model(None, "smart", "provider/model"))
4462            .unwrap();
4463        let before = hub.read_config_toml().unwrap();
4464
4465        assert!(matches!(
4466            hub.bind_default_model("provider/new"),
4467            Err(ConfigError::NameConflict {
4468                domain: "models",
4469                ..
4470            })
4471        ));
4472        assert_eq!(hub.read_config_toml().unwrap(), before);
4473    }
4474
4475    #[test]
4476    fn distinct_names_may_use_the_same_provider_model_id() {
4477        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4478        let (_dir, hub) = temp_hub();
4479        hub.upsert_model(model(None, "first", "provider/shared"))
4480            .unwrap();
4481        hub.upsert_model(model(None, "second", "provider/shared"))
4482            .unwrap();
4483
4484        let text = hub.read_config_toml().unwrap();
4485        assert!(text.contains("[models.first]"));
4486        assert!(text.contains("[models.second]"));
4487    }
4488
4489    #[test]
4490    fn mcp_upsert_preserves_existing_json_servers_and_overrides_toml_by_name() {
4491        let (_dir, hub) = temp_hub();
4492        std::fs::write(
4493            hub.config_toml_path(),
4494            "[[mcp]]\nname = \"shared\"\ncommand = \"from-toml\"\n",
4495        )
4496        .unwrap();
4497        hub.save_mcp(&[crate::mcp::McpServerConfig::stdio(
4498            "existing",
4499            "existing-command",
4500            vec![],
4501            crate::tool::Tier::Two,
4502            30_000,
4503        )])
4504        .unwrap();
4505
4506        hub.upsert_mcp(crate::mcp::McpServerConfig::stdio(
4507            "shared",
4508            "from-json",
4509            vec![],
4510            crate::tool::Tier::Three,
4511            30_000,
4512        ))
4513        .unwrap();
4514
4515        let configs = hub.load_local_mcp();
4516        assert_eq!(configs.len(), 2);
4517        assert_eq!(
4518            configs
4519                .iter()
4520                .find(|cfg| cfg.name == "shared")
4521                .unwrap()
4522                .command,
4523            "from-json"
4524        );
4525        assert!(configs.iter().any(|cfg| cfg.name == "existing"));
4526        assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
4527    }
4528
4529    #[test]
4530    fn mcp_toggle_toml_server_persists_json_override() {
4531        let (_dir, hub) = temp_hub();
4532        std::fs::write(
4533            hub.config_toml_path(),
4534            "[[mcp]]\nname = \"exa\"\ncommand = \"exa-mcp-server\"\n",
4535        )
4536        .unwrap();
4537
4538        assert!(hub.toggle_mcp("exa").unwrap());
4539
4540        let configs = hub.load_local_mcp();
4541        assert!(
4542            configs
4543                .iter()
4544                .find(|cfg| cfg.name == "exa")
4545                .unwrap()
4546                .disabled
4547        );
4548        assert!(hub.mcp_json_path().exists());
4549    }
4550
4551    #[test]
4552    fn mcp_remove_updates_json_atomically() {
4553        let (_dir, hub) = temp_hub();
4554        hub.save_mcp(&[
4555            crate::mcp::McpServerConfig::stdio(
4556                "first",
4557                "echo",
4558                vec![],
4559                crate::tool::Tier::Two,
4560                30_000,
4561            ),
4562            crate::mcp::McpServerConfig::stdio(
4563                "second",
4564                "ls",
4565                vec![],
4566                crate::tool::Tier::Two,
4567                30_000,
4568            ),
4569        ])
4570        .unwrap();
4571
4572        hub.remove_mcp("first").unwrap();
4573
4574        let configs = hub.load_local_mcp();
4575        assert_eq!(configs.len(), 1);
4576        assert_eq!(configs[0].name, "second");
4577        assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
4578    }
4579
4580    #[test]
4581    fn mcp_replace_renames_atomically_and_rejects_conflicts() {
4582        let (_dir, hub) = temp_hub();
4583        let mut first = crate::mcp::McpServerConfig::http(
4584            "first",
4585            "https://old.example",
4586            Some("secret".into()),
4587            crate::tool::Tier::Three,
4588            45_000,
4589        );
4590        first.headers = vec![("X-Test".into(), "value".into())];
4591        first.disabled = true;
4592        hub.save_mcp(&[
4593            first.clone(),
4594            crate::mcp::McpServerConfig::stdio(
4595                "second",
4596                "echo",
4597                vec![],
4598                crate::tool::Tier::Two,
4599                30_000,
4600            ),
4601        ])
4602        .unwrap();
4603
4604        first.name = "renamed".into();
4605        first.url = Some("https://new.example".into());
4606        hub.replace_mcp("first", first).unwrap();
4607
4608        let configs = hub.load_local_mcp();
4609        let renamed = configs
4610            .iter()
4611            .find(|config| config.name == "renamed")
4612            .unwrap();
4613        assert_eq!(renamed.auth_token.as_deref(), Some("secret"));
4614        assert_eq!(renamed.headers, [("X-Test".into(), "value".into())]);
4615        assert_eq!(renamed.timeout_ms, 45_000);
4616        assert!(renamed.disabled);
4617        assert!(configs.iter().all(|config| config.name != "first"));
4618        assert!(configs.iter().any(|config| config.name == "second"));
4619
4620        let before = std::fs::read_to_string(hub.mcp_json_path()).unwrap();
4621        let conflict = crate::mcp::McpServerConfig::stdio(
4622            "second",
4623            "false",
4624            vec![],
4625            crate::tool::Tier::One,
4626            1,
4627        );
4628        assert!(matches!(
4629            hub.replace_mcp("renamed", conflict),
4630            Err(ConfigError::NameConflict {
4631                domain: "MCP servers",
4632                ..
4633            })
4634        ));
4635        assert_eq!(
4636            std::fs::read_to_string(hub.mcp_json_path()).unwrap(),
4637            before
4638        );
4639        assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
4640    }
4641
4642    #[test]
4643    fn alias_updates_preserve_comments_and_other_sections() {
4644        let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
4645        let (_dir, hub) = temp_hub();
4646        std::fs::write(
4647            hub.config_toml_path(),
4648            "# keep me\n[theme]\nname = \"dark\"\n\n[alias.old]\nmodel = \"provider/a\"\n",
4649        )
4650        .unwrap();
4651
4652        hub.update_alias(Some("old"), "new", "provider/b").unwrap();
4653
4654        let text = hub.read_config_toml().unwrap();
4655        assert!(text.contains("# keep me"));
4656        assert!(text.contains("[theme]"));
4657        assert!(text.contains("[alias.new]"));
4658        assert!(!text.contains("[alias.old]"));
4659    }
4660}