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