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