Skip to main content

atman_runtime/
config_hub.rs

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