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