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