Skip to main content

atman_runtime/
config_hub.rs

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