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