1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4use std::sync::Mutex;
5
6use serde::{Deserialize, Serialize};
7
8use crate::model_registry::ModelConfigUpdate;
9
10static CONFIG_WRITE_LOCK: Mutex<()> = Mutex::new(());
11static AUTH_WRITE_LOCK: Mutex<()> = Mutex::new(());
12static ROUTES_WRITE_LOCK: Mutex<()> = Mutex::new(());
13static LAYOUT_MIGRATION_LOCK: Mutex<()> = Mutex::new(());
14
15#[derive(Debug)]
16pub enum ConfigError {
17 Io(std::io::Error),
18 Parse(toml_edit::TomlError),
19 Invalid(String),
20 NameConflict { name: String, domain: &'static str },
21}
22
23impl fmt::Display for ConfigError {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 match self {
26 Self::Io(error) => write!(f, "config I/O: {error}"),
27 Self::Parse(error) => write!(f, "parse config.toml: {error}"),
28 Self::Invalid(message) => f.write_str(message),
29 Self::NameConflict { name, domain } => {
30 write!(f, "config name {name:?} already exists in {domain}")
31 }
32 }
33 }
34}
35
36impl std::error::Error for ConfigError {}
37
38impl From<std::io::Error> for ConfigError {
39 fn from(error: std::io::Error) -> Self {
40 Self::Io(error)
41 }
42}
43
44impl From<toml_edit::TomlError> for ConfigError {
45 fn from(error: toml_edit::TomlError) -> Self {
46 Self::Parse(error)
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct DaemonConfig {
52 pub auth_token: String,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ThemePreference {
57 Auto,
58 Light,
59 Dark,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum InterjectionMode {
64 Off,
65 Rule,
66 Llm,
67 Unknown(String),
68}
69
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct RedactConfig {
72 pub enabled: bool,
73 pub partial: bool,
74 pub allowlist: Vec<String>,
75 pub custom_patterns: Vec<(String, String)>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SandboxConfig {
80 pub enabled: bool,
81 pub strict: bool,
82 pub extra_read: Vec<PathBuf>,
83 pub extra_write: Vec<PathBuf>,
84 pub template_path: Option<PathBuf>,
85 pub allow_network: bool,
86}
87
88impl Default for SandboxConfig {
89 fn default() -> Self {
90 Self {
91 enabled: true,
92 strict: false,
93 extra_read: Vec::new(),
94 extra_write: Vec::new(),
95 template_path: None,
96 allow_network: false,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy)]
102pub struct ProviderConfigUpdate<'a> {
103 pub name: &'a str,
104 pub kind: &'a str,
105 pub api_key: Option<&'a str>,
106 pub api_key_env: Option<&'a str>,
107 pub base_url: Option<&'a str>,
108 pub max_tokens: Option<u32>,
109 pub enabled: bool,
110}
111
112pub struct AuthTokenUpdate {
113 pub access_token: String,
114 pub refresh_token: Option<String>,
115 pub expires_at: i64,
116 pub account: Option<String>,
117}
118
119#[derive(Debug, Clone)]
120pub struct ConfigHub {
121 config_dir: PathBuf,
122 daemon_config_path: Option<PathBuf>,
123 auth_path: PathBuf,
124}
125
126impl ConfigHub {
127 pub fn global() -> Result<Self, ConfigError> {
128 let dir = crate::storage::config_dir()
129 .map_err(|error| ConfigError::Invalid(format!("config dir: {error}")))?;
130 Ok(Self::from_config_dir(dir))
131 }
132
133 pub fn from_config_dir(dir: impl Into<PathBuf>) -> Self {
134 let config_dir = dir.into();
135 let auth_path = config_dir.join("auth.json");
136 Self {
137 config_dir,
138 daemon_config_path: None,
139 auth_path,
140 }
141 }
142
143 pub fn config_dir(&self) -> &Path {
144 &self.config_dir
145 }
146
147 pub fn config_toml_path(&self) -> PathBuf {
148 self.config_dir.join("config.toml")
149 }
150
151 pub fn routes_at_path(&self) -> PathBuf {
152 self.config_dir.join("routes.at")
153 }
154
155 pub fn migrate_legacy_layout(
156 &self,
157 legacy_data_dir: &Path,
158 ) -> Result<Option<crate::config_migration::MigrationReport>, ConfigError> {
159 use fs2::FileExt;
160 let _guard = LAYOUT_MIGRATION_LOCK.lock().unwrap();
161 if legacy_data_dir == self.config_dir || !legacy_data_dir.exists() {
162 return Ok(None);
163 }
164 let lock = std::fs::OpenOptions::new()
165 .read(true)
166 .write(true)
167 .create(true)
168 .truncate(false)
169 .open(legacy_data_dir.join(".config-migration.lock"))?;
170 lock.lock_exclusive()?;
171 let _config_file_lock = lock_file(&self.config_dir.join(".config.toml.lock"))?;
172 let daemon_lock_path = self
173 .daemon_config_path
174 .as_deref()
175 .map(lock_path_for)
176 .unwrap_or_else(|| self.config_dir.join(".daemon.toml.lock"));
177 let _daemon_file_lock = lock_file(&daemon_lock_path)?;
178 let _routes_file_lock = lock_file(&self.config_dir.join(".routes.at.lock"))?;
179 crate::config_migration::relocate_legacy_layout(
180 &self.config_dir,
181 self.daemon_config_path.as_deref(),
182 legacy_data_dir,
183 )
184 .map_err(|error| ConfigError::Invalid(error.to_string()))
185 }
186
187 pub fn storage_config(&self, project_root: Option<&Path>) -> crate::storage::StorageConfig {
188 let global =
189 crate::storage::StorageConfig::load_from(&self.config_toml_path()).unwrap_or_default();
190 let project = project_root
191 .map(|root| {
192 crate::storage::StorageConfig::load_from(&root.join(".atman/config.toml"))
193 .unwrap_or_default()
194 })
195 .unwrap_or_default();
196 crate::storage::StorageConfig::merge(global, project)
197 }
198
199 pub fn load_routes_source(&self) -> Result<Option<String>, ConfigError> {
200 match std::fs::read_to_string(self.routes_at_path()) {
201 Ok(source) => Ok(Some(source)),
202 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
203 Err(error) => Err(ConfigError::Io(error)),
204 }
205 }
206
207 pub fn append_dsl_route(&self, flow_name: &str, trigger: &str) -> Result<(), ConfigError> {
208 use fs2::FileExt;
209
210 let route = dsl_route_source(flow_name, trigger)?;
211 let _guard = ROUTES_WRITE_LOCK.lock().unwrap();
212 std::fs::create_dir_all(&self.config_dir)?;
213 let lock = std::fs::OpenOptions::new()
214 .read(true)
215 .write(true)
216 .create(true)
217 .truncate(false)
218 .open(self.config_dir.join(".routes.at.lock"))?;
219 lock.lock_exclusive()?;
220
221 let path = self.routes_at_path();
222 let source = match std::fs::read_to_string(&path) {
223 Ok(source) => source,
224 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
225 Err(error) => return Err(error.into()),
226 };
227 parse_routes_source("existing routes.at", &source)?;
228
229 let mut combined = source;
230 if !combined.is_empty() && !combined.ends_with('\n') {
231 combined.push('\n');
232 }
233 combined.push_str(&route);
234 parse_routes_source("updated routes.at", &combined)?;
235 write_unique_atomic(&path, combined.as_bytes())
236 }
237
238 pub fn mcp_json_path(&self) -> PathBuf {
239 self.config_dir.join("mcp_servers.json")
240 }
241
242 pub fn from_daemon_config_path(path: impl Into<PathBuf>) -> Self {
243 let path = path.into();
244 let config_dir = path
245 .parent()
246 .unwrap_or_else(|| Path::new("."))
247 .to_path_buf();
248 Self::from_config_dir(config_dir).with_daemon_config_path(path)
249 }
250
251 pub fn with_daemon_config_path(mut self, path: impl Into<PathBuf>) -> Self {
252 self.daemon_config_path = Some(path.into());
253 self
254 }
255
256 pub fn from_auth_path(path: impl Into<PathBuf>) -> Self {
257 let auth_path = path.into();
258 let config_dir = auth_path
259 .parent()
260 .unwrap_or_else(|| Path::new("."))
261 .to_path_buf();
262 Self {
263 config_dir,
264 daemon_config_path: None,
265 auth_path,
266 }
267 }
268
269 pub fn load_auth(&self) -> Result<crate::auth_store::AuthStore, ConfigError> {
270 load_auth_from_path(&self.auth_path)
271 }
272
273 pub fn update_auth<T>(
274 &self,
275 mutate: impl FnOnce(&mut crate::auth_store::AuthStore) -> Result<T, ConfigError>,
276 ) -> Result<T, ConfigError> {
277 use fs2::FileExt;
278
279 let _guard = AUTH_WRITE_LOCK.lock().unwrap();
280 let parent = self.auth_path.parent().unwrap_or_else(|| Path::new("."));
281 std::fs::create_dir_all(parent)?;
282 let lock_path = parent.join(".auth.json.lock");
283 let lock = std::fs::OpenOptions::new()
284 .read(true)
285 .write(true)
286 .create(true)
287 .truncate(false)
288 .open(lock_path)?;
289 set_sensitive_file_permissions(
290 &self
291 .auth_path
292 .parent()
293 .unwrap_or_else(|| Path::new("."))
294 .join(".auth.json.lock"),
295 )?;
296 lock.lock_exclusive()?;
297 let mut store = load_auth_from_path(&self.auth_path)?;
298 let result = mutate(&mut store)?;
299 self.write_auth(&store)?;
300 Ok(result)
301 }
302
303 pub fn add_auth_provider(
304 &self,
305 provider: crate::auth_store::StoredProvider,
306 ) -> Result<(), ConfigError> {
307 self.update_auth(|store| {
308 store.providers.push(provider);
309 Ok(())
310 })
311 }
312
313 pub fn remove_auth_provider(&self, id: &str) -> Result<bool, ConfigError> {
314 self.update_auth(|store| Ok(store.remove(id)))
315 }
316
317 pub fn set_auth_provider_enabled(&self, id: &str, enabled: bool) -> Result<bool, ConfigError> {
318 self.update_auth(|store| {
319 let Some(provider) = store
320 .providers
321 .iter_mut()
322 .find(|provider| provider.id == id)
323 else {
324 return Ok(false);
325 };
326 provider.enabled = enabled;
327 Ok(true)
328 })
329 }
330
331 pub fn update_auth_tokens(
332 &self,
333 id: &str,
334 update: AuthTokenUpdate,
335 ) -> Result<bool, ConfigError> {
336 self.update_auth(|store| {
337 let Some(provider) = store
338 .providers
339 .iter_mut()
340 .find(|provider| provider.id == id)
341 else {
342 return Ok(false);
343 };
344 provider.access_token = update.access_token;
345 provider.expires_at = update.expires_at;
346 if update.refresh_token.is_some() {
347 provider.refresh_token = update.refresh_token;
348 }
349 if update.account.is_some() {
350 provider.account = update.account;
351 }
352 Ok(true)
353 })
354 }
355
356 pub fn update_auth_model_cache(
357 &self,
358 id: &str,
359 cache: crate::auth_store::ModelCache,
360 ) -> Result<bool, ConfigError> {
361 self.update_auth(|store| Ok(store.update_model_cache(id, cache)))
362 }
363
364 pub fn load_or_init_daemon_config(&self) -> Result<DaemonConfig, ConfigError> {
365 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
366 let path = self
367 .daemon_config_path
368 .as_deref()
369 .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
370 let _file_lock = lock_file(&lock_path_for(path))?;
371 match std::fs::read_to_string(path) {
372 Ok(text) => toml::from_str(&text).map_err(|error| {
373 ConfigError::Invalid(format!("parse {}: {error}", path.display()))
374 }),
375 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
376 let config = DaemonConfig {
377 auth_token: generate_daemon_token(),
378 };
379 self.write_daemon_config(&config)?;
380 Ok(config)
381 }
382 Err(error) => Err(error.into()),
383 }
384 }
385
386 pub fn rotate_daemon_config(&self) -> Result<DaemonConfig, ConfigError> {
387 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
388 let path = self
389 .daemon_config_path
390 .as_deref()
391 .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
392 let _file_lock = lock_file(&lock_path_for(path))?;
393 if !path.exists() {
394 return Err(ConfigError::Invalid(format!(
395 "no daemon config at {} — nothing to rotate. Run `atman daemon start` once to generate one.",
396 path.display()
397 )));
398 }
399 let config = DaemonConfig {
400 auth_token: generate_daemon_token(),
401 };
402 self.write_daemon_config(&config)?;
403 Ok(config)
404 }
405
406 pub fn read_config_toml(&self) -> Result<String, ConfigError> {
407 match std::fs::read_to_string(self.config_toml_path()) {
408 Ok(text) => Ok(text),
409 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
410 Err(error) => Err(error.into()),
411 }
412 }
413
414 pub fn theme_preference(&self) -> Result<ThemePreference, ConfigError> {
415 let text = self.read_config_toml()?;
416 if text.trim().is_empty() {
417 return Ok(ThemePreference::Auto);
418 }
419 let document = text.parse::<toml_edit::DocumentMut>()?;
420 let Some(theme) = document.get("theme") else {
421 return Ok(ThemePreference::Auto);
422 };
423 let Some(theme) = theme.as_table() else {
424 return Err(ConfigError::Invalid("theme is not a table".into()));
425 };
426 let Some(mode) = theme.get("mode") else {
427 return Ok(ThemePreference::Auto);
428 };
429 let Some(mode) = mode.as_str() else {
430 return Err(ConfigError::Invalid("theme.mode is not a string".into()));
431 };
432 match mode.to_ascii_lowercase().as_str() {
433 "auto" => Ok(ThemePreference::Auto),
434 "light" => Ok(ThemePreference::Light),
435 "dark" => Ok(ThemePreference::Dark),
436 _ => Err(ConfigError::Invalid(format!(
437 "invalid theme.mode: {mode:?}"
438 ))),
439 }
440 }
441
442 pub fn fs_access_mode(&self) -> Result<Option<crate::fs_access::FsAccessMode>, ConfigError> {
443 let text = self.read_config_toml()?;
444 if text.trim().is_empty() {
445 return Ok(None);
446 }
447 let document = text.parse::<toml_edit::DocumentMut>()?;
448 let Some(fs_access) = document.get("fs_access") else {
449 return Ok(None);
450 };
451 let Some(fs_access) = fs_access.as_table() else {
452 return Err(ConfigError::Invalid("fs_access is not a table".into()));
453 };
454 let Some(mode) = fs_access.get("mode") else {
455 return Ok(None);
456 };
457 let Some(mode) = mode.as_str() else {
458 return Err(ConfigError::Invalid(
459 "fs_access.mode is not a string".into(),
460 ));
461 };
462 crate::fs_access::FsAccessMode::from_str(mode)
463 .map(Some)
464 .map_err(ConfigError::Invalid)
465 }
466
467 pub fn auto_snapshot(&self) -> Result<Option<bool>, ConfigError> {
468 let text = self.read_config_toml()?;
469 if text.trim().is_empty() {
470 return Ok(None);
471 }
472 let document = text.parse::<toml_edit::DocumentMut>()?;
473 let Some(registry) = document.get("registry") else {
474 return Ok(None);
475 };
476 let Some(registry) = registry.as_table() else {
477 return Err(ConfigError::Invalid("registry is not a table".into()));
478 };
479 let Some(auto_snapshot) = registry.get("auto_snapshot") else {
480 return Ok(None);
481 };
482 if let Some(value) = auto_snapshot.as_bool() {
483 return Ok(Some(value));
484 }
485 if let Some(value) = auto_snapshot.as_integer() {
486 return Ok(Some(value == 1));
487 }
488 if let Some(value) = auto_snapshot.as_str() {
489 return Ok(Some(value == "true"));
490 }
491 Err(ConfigError::Invalid(
492 "registry.auto_snapshot has an unsupported type".into(),
493 ))
494 }
495
496 pub fn compact_review_mode(&self) -> Result<Option<crate::CompactReviewMode>, ConfigError> {
497 let text = self.read_config_toml()?;
498 if text.trim().is_empty() {
499 return Ok(None);
500 }
501 let document = text.parse::<toml_edit::DocumentMut>()?;
502 let Some(compaction) = document.get("compaction") else {
503 return Ok(None);
504 };
505 let Some(compaction) = compaction.as_table() else {
506 return Err(ConfigError::Invalid("compaction is not a table".into()));
507 };
508 let Some(review) = compaction.get("review") else {
509 return Ok(None);
510 };
511 let Some(review) = review.as_str() else {
512 return Err(ConfigError::Invalid(
513 "compaction.review is not a string".into(),
514 ));
515 };
516 crate::CompactReviewMode::parse(review)
517 .map(Some)
518 .ok_or_else(|| ConfigError::Invalid(format!("invalid compaction.review: {review:?}")))
519 }
520
521 pub fn suggest_model(&self) -> Result<Option<String>, ConfigError> {
522 let text = self.read_config_toml()?;
523 if text.trim().is_empty() {
524 return Ok(None);
525 }
526 let document = text.parse::<toml_edit::DocumentMut>()?;
527 let Some(suggest) = document.get("suggest") else {
528 return Ok(None);
529 };
530 let Some(suggest) = suggest.as_table() else {
531 return Err(ConfigError::Invalid("suggest is not a table".into()));
532 };
533 let Some(model) = suggest.get("model") else {
534 return Ok(None);
535 };
536 let Some(model) = model.as_str() else {
537 return Err(ConfigError::Invalid("suggest.model is not a string".into()));
538 };
539 Ok(Some(model.to_string()))
540 }
541
542 pub fn interjection_mode(&self) -> Result<Option<InterjectionMode>, ConfigError> {
543 let text = self.read_config_toml()?;
544 if text.trim().is_empty() {
545 return Ok(None);
546 }
547 let document = text.parse::<toml_edit::DocumentMut>()?;
548 let Some(interjection) = document.get("interjection") else {
549 return Ok(None);
550 };
551 let Some(interjection) = interjection.as_table() else {
552 return Err(ConfigError::Invalid("interjection is not a table".into()));
553 };
554 let Some(classifier) = interjection.get("classifier") else {
555 return Ok(None);
556 };
557 let Some(classifier) = classifier.as_str() else {
558 return Err(ConfigError::Invalid(
559 "interjection.classifier is not a string".into(),
560 ));
561 };
562 Ok(Some(match classifier {
563 "off" => InterjectionMode::Off,
564 "rule" => InterjectionMode::Rule,
565 "llm" => InterjectionMode::Llm,
566 other => InterjectionMode::Unknown(other.to_string()),
567 }))
568 }
569
570 pub fn web_fetch_config(&self) -> Result<crate::tools::web::WebConfig, ConfigError> {
571 #[derive(Debug, serde::Deserialize, Default)]
572 struct RawWeb {
573 #[serde(default)]
574 max_bytes: Option<usize>,
575 #[serde(default)]
576 url_allowlist: Vec<String>,
577 #[serde(default)]
578 url_denylist: Vec<String>,
579 }
580 #[derive(Debug, serde::Deserialize, Default)]
581 struct RawWebFile {
582 #[serde(default)]
583 web: RawWeb,
584 }
585
586 let text = self.read_config_toml()?;
587 let mut config = crate::tools::web::WebConfig::default();
588 if text.trim().is_empty() {
589 return Ok(config);
590 }
591 let file: RawWebFile = toml::from_str(&text)
592 .map_err(|error| ConfigError::Invalid(format!("parse web fetch config: {error}")))?;
593 if let Some(value) = file.web.max_bytes {
594 config.max_bytes = value;
595 }
596 if !file.web.url_allowlist.is_empty() {
597 config.url_allowlist = file.web.url_allowlist;
598 }
599 if !file.web.url_denylist.is_empty() {
600 config.url_denylist = file.web.url_denylist;
601 }
602 Ok(config)
603 }
604
605 pub fn web_search_config(&self) -> Result<crate::tools::web::SearchConfig, ConfigError> {
606 #[derive(Debug, serde::Deserialize, Default)]
607 struct RawWeb {
608 #[serde(default)]
609 search: Option<crate::tools::web::SearchConfig>,
610 }
611 #[derive(Debug, serde::Deserialize, Default)]
612 struct RawWebFile {
613 #[serde(default)]
614 web: RawWeb,
615 }
616
617 let text = self.read_config_toml()?;
618 if text.trim().is_empty() {
619 return Ok(crate::tools::web::SearchConfig::default());
620 }
621 let file: RawWebFile = toml::from_str(&text)
622 .map_err(|error| ConfigError::Invalid(format!("parse web search config: {error}")))?;
623 Ok(file.web.search.unwrap_or_default())
624 }
625
626 pub fn trust_config(&self) -> Result<crate::trust::TrustConfig, ConfigError> {
627 #[derive(Debug, serde::Deserialize, Default)]
628 struct RawTrustFile {
629 #[serde(default)]
630 trust: crate::trust::TrustConfig,
631 }
632
633 let text = self.read_config_toml()?;
634 if text.trim().is_empty() {
635 return Ok(crate::trust::TrustConfig::default());
636 }
637 let file: RawTrustFile = toml::from_str(&text)
638 .map_err(|error| ConfigError::Invalid(format!("parse trust config: {error}")))?;
639 Ok(file.trust)
640 }
641
642 pub fn preview_config(&self) -> Result<crate::tools::preview::PreviewConfig, ConfigError> {
643 #[derive(Debug, serde::Deserialize, Default)]
644 struct RawPreview {
645 #[serde(default)]
646 base_url: Option<String>,
647 #[serde(default)]
648 timeout_ms: Option<u64>,
649 #[serde(default)]
650 project_abs_path: Option<String>,
651 #[serde(default)]
652 project_hint_slug: Option<String>,
653 #[serde(default)]
654 max_body_bytes: Option<usize>,
655 }
656 #[derive(Debug, serde::Deserialize, Default)]
657 struct RawPreviewFile {
658 #[serde(default)]
659 preview: RawPreview,
660 }
661
662 let text = self.read_config_toml()?;
663 let mut config = crate::tools::preview::PreviewConfig::default();
664 if text.trim().is_empty() {
665 return Ok(config);
666 }
667 let file: RawPreviewFile = toml::from_str(&text)
668 .map_err(|error| ConfigError::Invalid(format!("parse preview config: {error}")))?;
669 if let Some(value) = file.preview.base_url {
670 config.base_url = value;
671 }
672 if let Some(value) = file.preview.timeout_ms {
673 config.timeout_ms = value;
674 }
675 if let Some(value) = file.preview.project_abs_path {
676 config.project_abs_path = value;
677 }
678 if let Some(value) = file.preview.project_hint_slug {
679 config.project_hint_slug = Some(value);
680 }
681 if let Some(value) = file.preview.max_body_bytes {
682 config.max_body_bytes = value;
683 }
684 Ok(config)
685 }
686
687 pub fn sandbox_config(&self) -> Result<SandboxConfig, ConfigError> {
688 #[derive(Debug, serde::Deserialize, Default)]
689 struct RawSandbox {
690 #[serde(default)]
691 enabled: Option<bool>,
692 #[serde(default)]
693 strict: bool,
694 #[serde(default)]
695 extra_read: Vec<String>,
696 #[serde(default)]
697 extra_write: Vec<String>,
698 #[serde(default)]
699 template_path: Option<String>,
700 #[serde(default)]
701 allow_network: Option<bool>,
702 }
703 #[derive(Debug, serde::Deserialize, Default)]
704 struct RawSandboxFile {
705 #[serde(default)]
706 sandbox: RawSandbox,
707 }
708
709 let text = self.read_config_toml()?;
710 if text.trim().is_empty() {
711 return Ok(SandboxConfig::default());
712 }
713 let file: RawSandboxFile = toml::from_str(&text)
714 .map_err(|error| ConfigError::Invalid(format!("parse sandbox config: {error}")))?;
715 Ok(SandboxConfig {
716 enabled: file.sandbox.enabled.unwrap_or(true),
717 strict: file.sandbox.strict,
718 extra_read: file
719 .sandbox
720 .extra_read
721 .into_iter()
722 .map(PathBuf::from)
723 .collect(),
724 extra_write: file
725 .sandbox
726 .extra_write
727 .into_iter()
728 .map(PathBuf::from)
729 .collect(),
730 template_path: file.sandbox.template_path.map(PathBuf::from),
731 allow_network: file.sandbox.allow_network.unwrap_or(false),
732 })
733 }
734
735 pub fn redact_config(&self) -> Result<RedactConfig, ConfigError> {
736 #[derive(Debug, serde::Deserialize, Default)]
737 struct RawPattern {
738 kind: String,
739 regex: String,
740 }
741 #[derive(Debug, serde::Deserialize, Default)]
742 struct RawRedact {
743 #[serde(default)]
744 enabled: bool,
745 #[serde(default)]
746 mode: Option<String>,
747 #[serde(default)]
748 allowlist: Vec<String>,
749 #[serde(default)]
750 custom_patterns: Vec<RawPattern>,
751 }
752 #[derive(Debug, serde::Deserialize, Default)]
753 struct RawRedactFile {
754 #[serde(default)]
755 redact: RawRedact,
756 }
757
758 let text = self.read_config_toml()?;
759 if text.trim().is_empty() {
760 return Ok(RedactConfig::default());
761 }
762 let file: RawRedactFile = toml::from_str(&text)
763 .map_err(|error| ConfigError::Invalid(format!("parse redact config: {error}")))?;
764 Ok(RedactConfig {
765 enabled: file.redact.enabled,
766 partial: file.redact.mode.as_deref() == Some("partial"),
767 allowlist: file.redact.allowlist,
768 custom_patterns: file
769 .redact
770 .custom_patterns
771 .into_iter()
772 .map(|pattern| (pattern.kind, pattern.regex))
773 .collect(),
774 })
775 }
776
777 pub fn upsert_model(&self, update: ModelConfigUpdate<'_>) -> Result<(), ConfigError> {
778 self.update_config_toml(|doc| {
779 validate_model_name(doc, update.old_name, update.name)?;
780 crate::model_registry::apply_model_config_update(doc, update)
781 .map_err(|error| ConfigError::Invalid(error.to_string()))
782 })
783 }
784
785 pub fn upsert_provider(&self, update: ProviderConfigUpdate<'_>) -> Result<(), ConfigError> {
786 self.update_config_toml(|doc| {
787 if doc.get("providers").is_none() {
788 doc.insert("providers", toml_edit::Item::Table(toml_edit::Table::new()));
789 }
790 let providers = doc
791 .get_mut("providers")
792 .and_then(toml_edit::Item::as_table_mut)
793 .ok_or_else(|| ConfigError::Invalid("providers is not a table".into()))?;
794 let mut entry = toml_edit::Table::new();
795 entry.insert("kind", toml_edit::value(update.kind));
796 insert_nonempty(&mut entry, "api_key", update.api_key);
797 insert_nonempty(&mut entry, "api_key_env", update.api_key_env);
798 insert_nonempty(&mut entry, "base_url", update.base_url);
799 if let Some(value) = update.max_tokens {
800 entry.insert("max_tokens", toml_edit::value(i64::from(value)));
801 }
802 entry.insert("enabled", toml_edit::value(update.enabled));
803 providers.insert(update.name, toml_edit::Item::Table(entry));
804 Ok(())
805 })
806 }
807
808 pub fn add_alias(&self, alias: &str, model: &str) -> Result<(), ConfigError> {
809 self.update_alias(None, alias, model)
810 }
811
812 pub fn bind_default_model(&self, model: &str) -> Result<(), ConfigError> {
813 self.update_config_toml(|doc| {
814 if table_contains(doc, "models", "smart")? {
815 return Err(ConfigError::NameConflict {
816 name: "smart".into(),
817 domain: "models",
818 });
819 }
820 ensure_alias_table(doc)?;
821 let aliases = doc
822 .get_mut("alias")
823 .and_then(toml_edit::Item::as_table_mut)
824 .ok_or_else(|| ConfigError::Invalid("alias is not a table".into()))?;
825 set_alias_model(aliases, "smart", model);
826 if !aliases.contains_key("cheap") {
827 set_alias_model(aliases, "cheap", "smart");
828 }
829 Ok(())
830 })
831 }
832
833 pub fn update_alias(
834 &self,
835 old_alias: Option<&str>,
836 new_alias: &str,
837 model: &str,
838 ) -> Result<(), ConfigError> {
839 self.update_config_toml(|doc| {
840 validate_alias_name(doc, old_alias, new_alias)?;
841 if doc.get("alias").is_none() {
842 doc.insert("alias", toml_edit::Item::Table(toml_edit::Table::new()));
843 }
844 let aliases = doc
845 .get_mut("alias")
846 .and_then(toml_edit::Item::as_table_mut)
847 .ok_or_else(|| ConfigError::Invalid("alias is not a table".into()))?;
848 if let Some(old) = old_alias.filter(|old| *old != new_alias) {
849 aliases.remove(old);
850 }
851 let mut entry = toml_edit::Table::new();
852 entry.insert("model", toml_edit::value(model));
853 aliases.insert(new_alias, toml_edit::Item::Table(entry));
854 Ok(())
855 })
856 }
857
858 pub fn remove_alias(&self, alias: &str) -> Result<(), ConfigError> {
859 self.update_config_toml(|doc| {
860 if let Some(aliases) = doc.get_mut("alias").and_then(toml_edit::Item::as_table_mut) {
861 aliases.remove(alias);
862 }
863 Ok(())
864 })
865 }
866
867 pub fn reload(&self) -> Result<(), ConfigError> {
868 let text = self.read_config_toml()?;
869 crate::model_registry::reload_from_text(&text)
870 .map_err(|error| ConfigError::Invalid(error.to_string()))
871 }
872
873 pub fn model_config(
874 &self,
875 ) -> Result<Option<crate::model_registry::ProviderConfig>, ConfigError> {
876 let text = self.read_config_toml()?;
877 if text.trim().is_empty() {
878 return Ok(None);
879 }
880 let document = text.parse::<toml_edit::DocumentMut>()?;
881 if document.get("providers").is_none()
882 && document.get("models").is_none()
883 && document.get("alias").is_none()
884 {
885 return Ok(None);
886 }
887 let has_model_entries = ["providers", "models", "alias"].iter().any(|section| {
888 document
889 .get(section)
890 .and_then(toml_edit::Item::as_table)
891 .is_some_and(|table| !table.is_empty())
892 });
893 if !has_model_entries {
894 return Ok(None);
895 }
896 crate::model_registry::parse_config(&text)
897 .ok_or_else(|| ConfigError::Invalid("invalid model configuration".into()))
898 .map(Some)
899 }
900
901 pub fn load_mcp(&self) -> Vec<crate::mcp::McpServerConfig> {
902 crate::mcp_config::load_from_dir(self.config_dir(), true)
903 }
904
905 pub fn load_local_mcp(&self) -> Vec<crate::mcp::McpServerConfig> {
906 crate::mcp_config::load_from_dir(self.config_dir(), false)
907 }
908
909 pub fn save_mcp(&self, configs: &[crate::mcp::McpServerConfig]) -> Result<(), ConfigError> {
910 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
911 self.write_mcp(configs)
912 }
913
914 pub fn upsert_mcp(&self, config: crate::mcp::McpServerConfig) -> Result<(), ConfigError> {
915 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
916 let mut configs = self.load_local_mcp();
917 configs.retain(|current| current.name != config.name);
918 configs.push(config);
919 self.write_mcp(&configs)
920 }
921
922 pub fn toggle_mcp(&self, name: &str) -> Result<bool, ConfigError> {
923 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
924 let mut configs = self.load_local_mcp();
925 let config = configs
926 .iter_mut()
927 .find(|config| config.name == name)
928 .ok_or_else(|| ConfigError::Invalid(format!("MCP server {name:?} not found")))?;
929 config.disabled = !config.disabled;
930 let disabled = config.disabled;
931 self.write_mcp(&configs)?;
932 Ok(disabled)
933 }
934
935 pub fn remove_mcp(&self, name: &str) -> Result<(), ConfigError> {
936 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
937 let mut configs = self.load_local_mcp();
938 let before = configs.len();
939 configs.retain(|config| config.name != name);
940 if configs.len() == before {
941 return Err(ConfigError::Invalid(format!(
942 "MCP server {name:?} not found"
943 )));
944 }
945 self.write_mcp(&configs)
946 }
947
948 pub fn migrate_and_reload_models(
949 &self,
950 ) -> Result<crate::model_registry::ModelMigrationOutcome, ConfigError> {
951 let outcome = self.migrate_model_config_if_needed()?;
952 self.reload()?;
953 Ok(outcome)
954 }
955
956 pub fn migrate_model_config_if_needed(
957 &self,
958 ) -> Result<crate::model_registry::ModelMigrationOutcome, ConfigError> {
959 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
960 let _file_lock = self.lock_config_file()?;
961 let text = self.read_config_toml()?;
962 let Some(migrated) = crate::model_registry::migrate_config_if_needed(&text)? else {
963 return Ok(crate::model_registry::ModelMigrationOutcome::NotNeeded);
964 };
965 let backup = self.config_dir.join("config.toml.bak");
966 write_sensitive_create_new_or_same(&backup, text.as_bytes())?;
967 self.write_config_toml(&migrated)?;
968 Ok(crate::model_registry::ModelMigrationOutcome::Migrated { backup })
969 }
970
971 fn lock_config_file(&self) -> Result<std::fs::File, ConfigError> {
972 lock_file(&self.config_dir.join(".config.toml.lock"))
973 }
974
975 fn update_config_toml(
976 &self,
977 mutate: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
978 ) -> Result<(), ConfigError> {
979 let _guard = CONFIG_WRITE_LOCK.lock().unwrap();
980 let _file_lock = self.lock_config_file()?;
981 let text = self.read_config_toml()?;
982 let mut doc = if text.trim().is_empty() {
983 toml_edit::DocumentMut::new()
984 } else {
985 text.parse()?
986 };
987 mutate(&mut doc)?;
988 let new_text = doc.to_string();
989 self.write_config_toml(&new_text)?;
990 crate::model_registry::reload_from_text(&new_text)
991 .map_err(|error| ConfigError::Invalid(error.to_string()))
992 }
993
994 fn write_config_toml(&self, text: &str) -> Result<(), ConfigError> {
995 write_unique_atomic(&self.config_toml_path(), text.as_bytes())
996 }
997
998 fn write_daemon_config(&self, config: &DaemonConfig) -> Result<(), ConfigError> {
999 let text = toml::to_string(config)
1000 .map_err(|error| ConfigError::Invalid(format!("serialize daemon config: {error}")))?;
1001 let path = self
1002 .daemon_config_path
1003 .as_deref()
1004 .ok_or_else(|| ConfigError::Invalid("daemon config path is not configured".into()))?;
1005 write_sensitive_atomic(path, text.as_bytes())
1006 }
1007
1008 fn write_auth(&self, store: &crate::auth_store::AuthStore) -> Result<(), ConfigError> {
1009 let json = serde_json::to_vec_pretty(store)
1010 .map_err(|error| ConfigError::Invalid(format!("serialize auth store: {error}")))?;
1011 write_sensitive_atomic(&self.auth_path, &json)
1012 }
1013
1014 fn write_mcp(&self, configs: &[crate::mcp::McpServerConfig]) -> Result<(), ConfigError> {
1015 let json = crate::mcp_config::serialize(configs)
1016 .map_err(|error| ConfigError::Invalid(format!("serialize mcp config: {error}")))?;
1017 self.write_atomic("mcp_servers.json", ".mcp_servers.json.tmp", &json)
1018 }
1019
1020 fn write_atomic(
1021 &self,
1022 filename: &str,
1023 temp_filename: &str,
1024 text: &str,
1025 ) -> Result<(), ConfigError> {
1026 std::fs::create_dir_all(&self.config_dir)?;
1027 let tmp = self.config_dir.join(temp_filename);
1028 std::fs::write(&tmp, text)?;
1029 std::fs::rename(tmp, self.config_dir.join(filename))?;
1030 Ok(())
1031 }
1032}
1033
1034fn dsl_route_source(flow_name: &str, trigger: &str) -> Result<String, ConfigError> {
1035 if syn_identifier(flow_name).is_none() {
1036 return Err(ConfigError::Invalid(format!(
1037 "route flow {flow_name:?} is not a valid DSL identifier"
1038 )));
1039 }
1040 if trigger.is_empty() {
1041 return Err(ConfigError::Invalid(
1042 "route trigger must not be empty".to_string(),
1043 ));
1044 }
1045 let trigger = format!("{trigger:?}");
1046 let route = format!("route {trigger} {{ flow: {flow_name} }}\n");
1047 parse_routes_source("generated route", &route)?;
1048 Ok(route)
1049}
1050
1051fn syn_identifier(value: &str) -> Option<()> {
1052 let source = format!("flow {value}() {{}}\n");
1053 atman_dsl::parse::parse_file(&source).ok().map(|_| ())
1054}
1055
1056fn parse_routes_source(context: &str, source: &str) -> Result<(), ConfigError> {
1057 if source.is_empty() {
1058 return Ok(());
1059 }
1060 atman_dsl::parse::parse_file(source)
1061 .map(|_| ())
1062 .map_err(|error| ConfigError::Invalid(format!("parse {context}: {error}")))
1063}
1064
1065fn lock_path_for(path: &Path) -> PathBuf {
1066 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1067 let name = path
1068 .file_name()
1069 .and_then(|name| name.to_str())
1070 .unwrap_or("config");
1071 parent.join(format!(".{name}.lock"))
1072}
1073
1074fn lock_file(path: &Path) -> Result<std::fs::File, ConfigError> {
1075 use fs2::FileExt;
1076 std::fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new(".")))?;
1077 let lock = std::fs::OpenOptions::new()
1078 .read(true)
1079 .write(true)
1080 .create(true)
1081 .truncate(false)
1082 .open(path)?;
1083 lock.lock_exclusive()?;
1084 Ok(lock)
1085}
1086
1087fn write_unique_atomic(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
1088 use std::io::Write;
1089
1090 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1091 std::fs::create_dir_all(parent)?;
1092 let filename = path
1093 .file_name()
1094 .and_then(|name| name.to_str())
1095 .unwrap_or("config");
1096 let tmp = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4().simple()));
1097 let result = (|| -> Result<(), ConfigError> {
1098 let mut file = std::fs::OpenOptions::new()
1099 .write(true)
1100 .create_new(true)
1101 .open(&tmp)?;
1102 file.write_all(contents)?;
1103 file.sync_all()?;
1104 drop(file);
1105 std::fs::rename(&tmp, path)?;
1106 Ok(())
1107 })();
1108 if result.is_err() {
1109 let _ = std::fs::remove_file(&tmp);
1110 }
1111 result
1112}
1113
1114fn load_auth_from_path(path: &Path) -> Result<crate::auth_store::AuthStore, ConfigError> {
1115 match std::fs::read(path) {
1116 Ok(bytes) => serde_json::from_slice(&bytes)
1117 .map_err(|error| ConfigError::Invalid(format!("parse {}: {error}", path.display()))),
1118 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1119 Ok(crate::auth_store::AuthStore::default())
1120 }
1121 Err(error) => Err(error.into()),
1122 }
1123}
1124
1125fn write_sensitive_create_new_or_same(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
1126 match std::fs::read(path) {
1127 Ok(existing) if existing == contents => return Ok(()),
1128 Ok(_) => {
1129 return Err(ConfigError::Invalid(format!(
1130 "backup conflict at {}",
1131 path.display()
1132 )));
1133 }
1134 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1135 Err(error) => return Err(error.into()),
1136 }
1137 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1138 std::fs::create_dir_all(parent)?;
1139 let mut options = std::fs::OpenOptions::new();
1140 options.write(true).create_new(true);
1141 #[cfg(unix)]
1142 {
1143 use std::os::unix::fs::OpenOptionsExt;
1144 options.mode(0o600);
1145 }
1146 use std::io::Write;
1147 let mut file = options.open(path)?;
1148 set_sensitive_file_permissions(path)?;
1149 if let Err(error) = file.write_all(contents).and_then(|_| file.sync_all()) {
1150 drop(file);
1151 let _ = std::fs::remove_file(path);
1152 return Err(error.into());
1153 }
1154 Ok(())
1155}
1156
1157fn write_sensitive_atomic(path: &Path, contents: &[u8]) -> Result<(), ConfigError> {
1158 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1159 std::fs::create_dir_all(parent)?;
1160 let filename = path
1161 .file_name()
1162 .and_then(|name| name.to_str())
1163 .unwrap_or("sensitive-config");
1164 let tmp = parent.join(format!(".{filename}.{}.tmp", uuid::Uuid::new_v4().simple()));
1165 let mut options = std::fs::OpenOptions::new();
1166 options.write(true).create_new(true);
1167 #[cfg(unix)]
1168 {
1169 use std::os::unix::fs::OpenOptionsExt;
1170 options.mode(0o600);
1171 }
1172 let mut file = options.open(&tmp)?;
1173 set_sensitive_file_permissions(&tmp)?;
1174 use std::io::Write;
1175 file.write_all(contents)?;
1176 drop(file);
1177 std::fs::rename(&tmp, path)?;
1178 Ok(())
1179}
1180
1181fn generate_daemon_token() -> String {
1182 let first = uuid::Uuid::new_v4().simple().to_string();
1183 let second = uuid::Uuid::new_v4().simple().to_string();
1184 format!("{first}{second}")
1185}
1186
1187fn set_sensitive_file_permissions(path: &Path) -> Result<(), ConfigError> {
1188 #[cfg(unix)]
1189 {
1190 use std::os::unix::fs::PermissionsExt;
1191 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
1192 }
1193 Ok(())
1194}
1195
1196fn insert_nonempty(table: &mut toml_edit::Table, key: &str, value: Option<&str>) {
1197 if let Some(value) = value.filter(|value| !value.is_empty()) {
1198 table.insert(key, toml_edit::value(value));
1199 }
1200}
1201
1202fn ensure_alias_table(doc: &mut toml_edit::DocumentMut) -> Result<(), ConfigError> {
1203 if doc.get("alias").is_none() {
1204 doc.insert("alias", toml_edit::Item::Table(toml_edit::Table::new()));
1205 }
1206 if doc
1207 .get("alias")
1208 .and_then(toml_edit::Item::as_table)
1209 .is_none()
1210 {
1211 return Err(ConfigError::Invalid("alias is not a table".into()));
1212 }
1213 Ok(())
1214}
1215
1216fn set_alias_model(table: &mut toml_edit::Table, alias: &str, model: &str) {
1217 let mut entry = toml_edit::Table::new();
1218 entry.insert("model", toml_edit::value(model));
1219 table.insert(alias, toml_edit::Item::Table(entry));
1220}
1221
1222fn validate_model_name(
1223 doc: &toml_edit::DocumentMut,
1224 old_name: Option<&str>,
1225 name: &str,
1226) -> Result<(), ConfigError> {
1227 if old_name != Some(name) && table_contains(doc, "models", name)? {
1228 return Err(ConfigError::NameConflict {
1229 name: name.into(),
1230 domain: "models",
1231 });
1232 }
1233 if table_contains(doc, "alias", name)? {
1234 return Err(ConfigError::NameConflict {
1235 name: name.into(),
1236 domain: "alias",
1237 });
1238 }
1239 Ok(())
1240}
1241
1242fn validate_alias_name(
1243 doc: &toml_edit::DocumentMut,
1244 old_name: Option<&str>,
1245 name: &str,
1246) -> Result<(), ConfigError> {
1247 if table_contains(doc, "models", name)? {
1248 return Err(ConfigError::NameConflict {
1249 name: name.into(),
1250 domain: "models",
1251 });
1252 }
1253 if old_name != Some(name) && table_contains(doc, "alias", name)? {
1254 return Err(ConfigError::NameConflict {
1255 name: name.into(),
1256 domain: "alias",
1257 });
1258 }
1259 Ok(())
1260}
1261
1262fn table_contains(
1263 doc: &toml_edit::DocumentMut,
1264 table: &'static str,
1265 name: &str,
1266) -> Result<bool, ConfigError> {
1267 match doc.get(table) {
1268 None => Ok(false),
1269 Some(item) => item
1270 .as_table()
1271 .map(|items| items.contains_key(name))
1272 .ok_or_else(|| ConfigError::Invalid(format!("{table} is not a table"))),
1273 }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279
1280 fn temp_hub() -> (tempfile::TempDir, ConfigHub) {
1281 let dir = tempfile::tempdir().unwrap();
1282 let hub = ConfigHub::from_config_dir(dir.path());
1283 (dir, hub)
1284 }
1285
1286 #[test]
1287 fn storage_config_merges_only_typed_storage_projection() {
1288 let (dir, hub) = temp_hub();
1289 std::fs::write(
1290 dir.path().join("config.toml"),
1291 "[storage]\nscope = \"local\"\n[theme]\nmode = \"dark\"\n",
1292 )
1293 .unwrap();
1294 let project = tempfile::tempdir().unwrap();
1295 std::fs::create_dir(project.path().join(".atman")).unwrap();
1296 std::fs::write(
1297 project.path().join(".atman/config.toml"),
1298 "[storage]\nscope = \"global\"\n[theme]\nmode = \"light\"\n",
1299 )
1300 .unwrap();
1301
1302 assert_eq!(
1303 hub.storage_config(Some(project.path())).scope,
1304 Some(crate::storage::StorageScope::Global)
1305 );
1306 }
1307
1308 #[test]
1309 fn storage_config_isolates_invalid_global_and_project_layers() {
1310 let (dir, hub) = temp_hub();
1311 let project = tempfile::tempdir().unwrap();
1312 std::fs::create_dir(project.path().join(".atman")).unwrap();
1313 std::fs::write(dir.path().join("config.toml"), "not valid [").unwrap();
1314 std::fs::write(
1315 project.path().join(".atman/config.toml"),
1316 "[storage]\nscope = \"local\"\n",
1317 )
1318 .unwrap();
1319 assert_eq!(
1320 hub.storage_config(Some(project.path())).scope,
1321 Some(crate::storage::StorageScope::Local)
1322 );
1323
1324 std::fs::write(
1325 dir.path().join("config.toml"),
1326 "[storage]\nscope = \"global\"\n",
1327 )
1328 .unwrap();
1329 std::fs::write(project.path().join(".atman/config.toml"), "not valid [").unwrap();
1330 assert_eq!(
1331 hub.storage_config(Some(project.path())).scope,
1332 Some(crate::storage::StorageScope::Global)
1333 );
1334 }
1335
1336 #[test]
1337 fn storage_config_treats_read_errors_as_empty_layers() {
1338 let (dir, hub) = temp_hub();
1339 std::fs::create_dir(dir.path().join("config.toml")).unwrap();
1340 let project = tempfile::tempdir().unwrap();
1341 std::fs::create_dir(project.path().join(".atman")).unwrap();
1342 std::fs::write(
1343 project.path().join(".atman/config.toml"),
1344 "[storage]\nscope = \"local\"\n",
1345 )
1346 .unwrap();
1347
1348 assert_eq!(
1349 hub.storage_config(Some(project.path())).scope,
1350 Some(crate::storage::StorageScope::Local)
1351 );
1352 }
1353
1354 fn write_config(hub: &ConfigHub, text: &str) {
1355 std::fs::write(hub.config_toml_path(), text).unwrap();
1356 }
1357
1358 #[test]
1359 fn append_dsl_route_creates_missing_file_and_escapes_trigger() {
1360 let (_dir, hub) = temp_hub();
1361 hub.append_dsl_route("review_code", "say \"hi\"\\now\n")
1362 .unwrap();
1363
1364 let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1365 let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1366 assert_eq!(parsed.routes.len(), 1);
1367 assert_eq!(parsed.routes[0].pattern, "say \"hi\"\\now\n");
1368 assert_eq!(parsed.routes[0].flow.name, "review_code");
1369 }
1370
1371 #[test]
1372 fn append_dsl_route_preserves_existing_source_exactly() {
1373 let (_dir, hub) = temp_hub();
1374 let original = "// keep this comment\nroute \"old \" { flow: old_flow }";
1375 std::fs::write(hub.routes_at_path(), original).unwrap();
1376
1377 hub.append_dsl_route("new_flow", "new ").unwrap();
1378
1379 assert_eq!(
1380 std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1381 format!("{original}\nroute \"new \" {{ flow: new_flow }}\n")
1382 );
1383 }
1384
1385 #[test]
1386 fn append_dsl_route_does_not_overwrite_invalid_existing_source() {
1387 let (_dir, hub) = temp_hub();
1388 let invalid = "route invalid";
1389 std::fs::write(hub.routes_at_path(), invalid).unwrap();
1390
1391 let error = hub.append_dsl_route("new_flow", "new ").unwrap_err();
1392
1393 assert!(error.to_string().contains("parse existing routes.at"));
1394 assert_eq!(
1395 std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1396 invalid
1397 );
1398 }
1399
1400 #[test]
1401 fn append_dsl_route_rejects_invalid_flow_without_writing() {
1402 let (_dir, hub) = temp_hub();
1403 let error = hub.append_dsl_route("bad-name", "new ").unwrap_err();
1404 assert!(error.to_string().contains("valid DSL identifier"));
1405 assert!(!hub.routes_at_path().exists());
1406 }
1407
1408 #[test]
1409 fn concurrent_dsl_route_appends_do_not_lose_updates() {
1410 let (_dir, hub) = temp_hub();
1411 let mut workers = Vec::new();
1412 for index in 0..12 {
1413 let hub = hub.clone();
1414 workers.push(std::thread::spawn(move || {
1415 hub.append_dsl_route(&format!("flow_{index}"), &format!("{index} "))
1416 .unwrap();
1417 }));
1418 }
1419 for worker in workers {
1420 worker.join().unwrap();
1421 }
1422
1423 let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1424 let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1425 assert_eq!(parsed.routes.len(), 12);
1426 for index in 0..12 {
1427 assert!(parsed.routes.iter().any(|route| {
1428 route.flow.name == format!("flow_{index}") && route.pattern == format!("{index} ")
1429 }));
1430 }
1431 assert!(!std::fs::read_dir(hub.config_dir()).unwrap().any(|entry| {
1432 let name = entry.unwrap().file_name();
1433 let name = name.to_string_lossy();
1434 name.starts_with(".routes.at.") && name.ends_with(".tmp")
1435 }));
1436 }
1437
1438 #[test]
1439 fn append_dsl_route_waits_for_external_file_lock() {
1440 use fs2::FileExt;
1441 use std::sync::mpsc::TryRecvError;
1442
1443 let (_dir, hub) = temp_hub();
1444 let lock = std::fs::OpenOptions::new()
1445 .read(true)
1446 .write(true)
1447 .create(true)
1448 .truncate(false)
1449 .open(hub.config_dir().join(".routes.at.lock"))
1450 .unwrap();
1451 lock.lock_exclusive().unwrap();
1452
1453 let worker_hub = hub.clone();
1454 let (tx, rx) = std::sync::mpsc::channel();
1455 let worker = std::thread::spawn(move || {
1456 worker_hub.append_dsl_route("blocked", "wait ").unwrap();
1457 tx.send(()).unwrap();
1458 });
1459 assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1460 FileExt::unlock(&lock).unwrap();
1461 rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1462 worker.join().unwrap();
1463 }
1464
1465 #[test]
1466 fn model_config_projection_handles_missing_valid_and_invalid_files() {
1467 let (_dir, hub) = temp_hub();
1468 assert!(hub.model_config().unwrap().is_none());
1469
1470 write_config(
1471 &hub,
1472 "[providers.openai]\nkind = \"openai\"\n[models.fast]\nmodel = \"gpt-4o-mini\"\n[alias.default]\nmodel = \"fast\"\n",
1473 );
1474 let config = hub.model_config().unwrap().unwrap();
1475 assert_eq!(config.providers["openai"].kind, "openai");
1476 assert_eq!(config.models["fast"].model, "gpt-4o-mini");
1477 assert_eq!(config.aliases["default"].model, "fast");
1478
1479 write_config(&hub, "[models]\n");
1480 assert!(hub.model_config().unwrap().is_none());
1481
1482 write_config(&hub, "[models\n");
1483 assert!(hub.model_config().is_err());
1484 }
1485
1486 #[test]
1487 fn theme_preference_defaults_to_auto_when_config_is_missing() {
1488 let (_dir, hub) = temp_hub();
1489
1490 assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1491 }
1492
1493 #[test]
1494 fn theme_preference_defaults_to_auto_when_mode_is_missing() {
1495 let (_dir, hub) = temp_hub();
1496 write_config(&hub, "[theme]\n");
1497
1498 assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1499 }
1500
1501 #[test]
1502 fn theme_preference_parses_supported_modes() {
1503 for (mode, expected) in [
1504 ("auto", ThemePreference::Auto),
1505 ("light", ThemePreference::Light),
1506 ("LiGhT", ThemePreference::Light),
1507 ("dark", ThemePreference::Dark),
1508 ] {
1509 let (_dir, hub) = temp_hub();
1510 write_config(&hub, &format!("[theme]\nmode = {mode:?}\n"));
1511
1512 assert_eq!(hub.theme_preference().unwrap(), expected);
1513 }
1514 }
1515
1516 #[test]
1517 fn theme_preference_rejects_unknown_mode() {
1518 let (_dir, hub) = temp_hub();
1519 write_config(&hub, "[theme]\nmode = \"sepia\"\n");
1520
1521 assert!(matches!(
1522 hub.theme_preference(),
1523 Err(ConfigError::Invalid(message)) if message.contains("theme.mode")
1524 ));
1525 }
1526
1527 fn auth_provider(id: &str) -> crate::auth_store::StoredProvider {
1528 crate::auth_store::StoredProvider {
1529 id: id.into(),
1530 name: id.into(),
1531 kind: crate::auth_store::ProviderKind::Codex,
1532 access_token: "old-access".into(),
1533 refresh_token: Some("old-refresh".into()),
1534 expires_at: 1,
1535 account: Some("old-account".into()),
1536 enabled: true,
1537 model_cache: None,
1538 }
1539 }
1540
1541 #[test]
1542 fn auth_transactions_preserve_independent_concurrent_updates() {
1543 let dir = tempfile::tempdir().unwrap();
1544 let path = dir.path().join("auth.json");
1545 let hub = ConfigHub::from_auth_path(&path);
1546 hub.add_auth_provider(auth_provider("provider")).unwrap();
1547
1548 let cache_hub = hub.clone();
1549 let cache = std::thread::spawn(move || {
1550 cache_hub
1551 .update_auth_model_cache(
1552 "provider",
1553 crate::auth_store::ModelCache {
1554 fetched_at: 10,
1555 models: vec![crate::auth_store::CachedModel {
1556 slug: "cached-model".into(),
1557 context_budget: Some(8192),
1558 thinking: true,
1559 }],
1560 },
1561 )
1562 .unwrap();
1563 });
1564 let token_hub = hub.clone();
1565 let tokens = std::thread::spawn(move || {
1566 token_hub
1567 .update_auth_tokens(
1568 "provider",
1569 AuthTokenUpdate {
1570 access_token: "new-access".into(),
1571 refresh_token: Some("new-refresh".into()),
1572 expires_at: 99,
1573 account: None,
1574 },
1575 )
1576 .unwrap();
1577 });
1578 let enabled_hub = hub.clone();
1579 let enabled = std::thread::spawn(move || {
1580 enabled_hub
1581 .set_auth_provider_enabled("provider", false)
1582 .unwrap();
1583 });
1584 cache.join().unwrap();
1585 tokens.join().unwrap();
1586 enabled.join().unwrap();
1587
1588 let store = hub.load_auth().unwrap();
1589 let provider = &store.providers[0];
1590 assert_eq!(provider.access_token, "new-access");
1591 assert_eq!(provider.refresh_token.as_deref(), Some("new-refresh"));
1592 assert_eq!(provider.expires_at, 99);
1593 assert_eq!(provider.account.as_deref(), Some("old-account"));
1594 assert!(!provider.enabled);
1595 assert_eq!(
1596 provider.model_cache.as_ref().unwrap().models[0].slug,
1597 "cached-model"
1598 );
1599 #[cfg(unix)]
1600 {
1601 use std::os::unix::fs::PermissionsExt;
1602 assert_eq!(
1603 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1604 0o600
1605 );
1606 }
1607 assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1608 let name = entry.unwrap().file_name();
1609 let name = name.to_string_lossy();
1610 name.starts_with(".auth.json.") && name.ends_with(".tmp")
1611 }));
1612 }
1613
1614 #[test]
1615 fn auth_transaction_waits_for_external_file_lock() {
1616 use fs2::FileExt;
1617 use std::sync::mpsc::TryRecvError;
1618
1619 let dir = tempfile::tempdir().unwrap();
1620 let path = dir.path().join("auth.json");
1621 let lock_path = dir.path().join(".auth.json.lock");
1622 let lock = std::fs::OpenOptions::new()
1623 .read(true)
1624 .write(true)
1625 .create(true)
1626 .truncate(false)
1627 .open(lock_path)
1628 .unwrap();
1629 lock.lock_exclusive().unwrap();
1630
1631 let hub = ConfigHub::from_auth_path(&path);
1632 let (tx, rx) = std::sync::mpsc::channel();
1633 let worker = std::thread::spawn(move || {
1634 hub.add_auth_provider(auth_provider("blocked")).unwrap();
1635 tx.send(()).unwrap();
1636 });
1637 assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1638 FileExt::unlock(&lock).unwrap();
1639 rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1640 worker.join().unwrap();
1641 }
1642
1643 #[test]
1644 fn auth_transaction_error_rolls_back_without_writing() {
1645 let dir = tempfile::tempdir().unwrap();
1646 let path = dir.path().join("auth.json");
1647 let hub = ConfigHub::from_auth_path(&path);
1648 hub.add_auth_provider(auth_provider("original")).unwrap();
1649 let before = std::fs::read(&path).unwrap();
1650
1651 let result: Result<(), ConfigError> = hub.update_auth(|store| {
1652 store.providers.push(auth_provider("discarded"));
1653 Err(ConfigError::Invalid("reject mutation".into()))
1654 });
1655 assert!(result.is_err());
1656 assert_eq!(std::fs::read(&path).unwrap(), before);
1657 }
1658
1659 #[test]
1660 fn auth_transaction_does_not_overwrite_corrupt_json() {
1661 let dir = tempfile::tempdir().unwrap();
1662 let path = dir.path().join("auth.json");
1663 let corrupt = b"{not-json";
1664 std::fs::write(&path, corrupt).unwrap();
1665 let hub = ConfigHub::from_auth_path(&path);
1666
1667 let err = hub.add_auth_provider(auth_provider("new")).unwrap_err();
1668 assert!(err.to_string().contains("parse"));
1669 assert_eq!(std::fs::read(&path).unwrap(), corrupt);
1670 }
1671
1672 #[test]
1673 fn auth_load_defaults_when_file_is_missing() {
1674 let dir = tempfile::tempdir().unwrap();
1675 let hub = ConfigHub::from_auth_path(dir.path().join("auth.json"));
1676 assert!(hub.load_auth().unwrap().providers.is_empty());
1677 }
1678
1679 #[test]
1680 fn daemon_config_initializes_reuses_and_rotates_atomically() {
1681 let dir = tempfile::tempdir().unwrap();
1682 let path = dir.path().join("daemon.toml");
1683 let hub = ConfigHub::from_daemon_config_path(&path);
1684
1685 let first = hub.load_or_init_daemon_config().unwrap();
1686 assert_eq!(first.auth_token.len(), 64);
1687 assert!(first.auth_token.chars().all(|c| c.is_ascii_hexdigit()));
1688 let second = hub.load_or_init_daemon_config().unwrap();
1689 assert_eq!(second, first);
1690 assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1691 let name = entry.unwrap().file_name();
1692 let name = name.to_string_lossy();
1693 name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1694 }));
1695 #[cfg(unix)]
1696 {
1697 use std::os::unix::fs::PermissionsExt;
1698 assert_eq!(
1699 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1700 0o600
1701 );
1702 }
1703
1704 let rotated = hub.rotate_daemon_config().unwrap();
1705 assert_ne!(rotated.auth_token, first.auth_token);
1706 assert_eq!(hub.load_or_init_daemon_config().unwrap(), rotated);
1707 assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1708 let name = entry.unwrap().file_name();
1709 let name = name.to_string_lossy();
1710 name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1711 }));
1712 }
1713
1714 #[test]
1715 fn daemon_config_waits_for_external_file_lock() {
1716 use std::sync::mpsc::TryRecvError;
1717
1718 let dir = tempfile::tempdir().unwrap();
1719 let path = dir.path().join("custom-daemon.toml");
1720 let lock = lock_file(&lock_path_for(&path)).unwrap();
1721 let hub = ConfigHub::from_daemon_config_path(&path);
1722 let (tx, rx) = std::sync::mpsc::channel();
1723 let worker = std::thread::spawn(move || {
1724 tx.send(hub.load_or_init_daemon_config()).unwrap();
1725 });
1726
1727 std::thread::sleep(std::time::Duration::from_millis(25));
1728 assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1729 lock.unlock().unwrap();
1730 assert!(
1731 rx.recv_timeout(std::time::Duration::from_secs(1))
1732 .unwrap()
1733 .is_ok()
1734 );
1735 worker.join().unwrap();
1736 }
1737
1738 #[cfg(unix)]
1739 #[test]
1740 fn legacy_daemon_config_uses_custom_path_and_owner_only_permissions() {
1741 use std::os::unix::fs::PermissionsExt;
1742
1743 let config = tempfile::tempdir().unwrap();
1744 let data = tempfile::tempdir().unwrap();
1745 let daemon_path = config.path().join("daemon/custom.toml");
1746 std::fs::write(data.path().join("daemon.toml"), "auth_token = \"legacy\"\n").unwrap();
1747
1748 let report = ConfigHub::from_config_dir(config.path())
1749 .with_daemon_config_path(&daemon_path)
1750 .migrate_legacy_layout(data.path())
1751 .unwrap()
1752 .unwrap();
1753
1754 assert!(report.moved.iter().any(|path| path == "daemon.toml"));
1755 assert_eq!(
1756 std::fs::read_to_string(&daemon_path).unwrap(),
1757 "auth_token = \"legacy\"\n"
1758 );
1759 assert!(!config.path().join("daemon.toml").exists());
1760 assert_eq!(
1761 std::fs::metadata(&daemon_path)
1762 .unwrap()
1763 .permissions()
1764 .mode()
1765 & 0o777,
1766 0o600
1767 );
1768 }
1769
1770 #[test]
1771 fn daemon_config_rotation_requires_existing_file() {
1772 let dir = tempfile::tempdir().unwrap();
1773 let path = dir.path().join("missing.toml");
1774 let err = ConfigHub::from_daemon_config_path(&path)
1775 .rotate_daemon_config()
1776 .unwrap_err();
1777 assert!(err.to_string().contains("no daemon config"));
1778 }
1779
1780 #[test]
1781 fn web_configs_default_when_config_or_section_is_missing() {
1782 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1783 let (_dir, hub) = temp_hub();
1784 if let Some(text) = text {
1785 write_config(&hub, text);
1786 }
1787
1788 let fetch = hub.web_fetch_config().unwrap();
1789 assert_eq!(fetch.max_bytes, 1_000_000);
1790 assert!(fetch.url_allowlist.is_empty());
1791 assert!(fetch.url_denylist.is_empty());
1792 let search = hub.web_search_config().unwrap();
1793 assert_eq!(search.provider_name(), "tavily");
1794 }
1795 }
1796
1797 #[test]
1798 fn web_configs_parse_fetch_and_search_fields() {
1799 let (_dir, hub) = temp_hub();
1800 write_config(
1801 &hub,
1802 r#"
1803[web]
1804max_bytes = 4096
1805url_allowlist = ["https://ok.example"]
1806url_denylist = ["https://ok.example/private"]
1807
1808[web.search]
1809provider = "searxng"
1810base_url = "http://localhost:8080"
1811max_results = 6
1812"#,
1813 );
1814
1815 let fetch = hub.web_fetch_config().unwrap();
1816 assert_eq!(fetch.max_bytes, 4096);
1817 assert_eq!(fetch.url_allowlist, vec!["https://ok.example"]);
1818 assert_eq!(fetch.url_denylist, vec!["https://ok.example/private"]);
1819 assert_eq!(hub.web_search_config().unwrap().provider_name(), "searxng");
1820 }
1821
1822 #[test]
1823 fn web_fetch_schema_error_does_not_break_valid_search() {
1824 let (_dir, hub) = temp_hub();
1825 write_config(
1826 &hub,
1827 "[web]\nmax_bytes = \"large\"\n[web.search]\nprovider = \"none\"\n",
1828 );
1829
1830 assert!(matches!(
1831 hub.web_fetch_config(),
1832 Err(ConfigError::Invalid(_))
1833 ));
1834 assert_eq!(hub.web_search_config().unwrap().provider_name(), "none");
1835 }
1836
1837 #[test]
1838 fn web_search_schema_error_does_not_break_valid_fetch() {
1839 let (_dir, hub) = temp_hub();
1840 write_config(
1841 &hub,
1842 "[web]\nmax_bytes = 2048\n[web.search]\nprovider = \"unknown\"\n",
1843 );
1844
1845 assert_eq!(hub.web_fetch_config().unwrap().max_bytes, 2048);
1846 assert!(matches!(
1847 hub.web_search_config(),
1848 Err(ConfigError::Invalid(_))
1849 ));
1850 }
1851
1852 #[test]
1853 fn trust_config_defaults_when_config_or_section_is_missing() {
1854 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1855 let (_dir, hub) = temp_hub();
1856 if let Some(text) = text {
1857 write_config(&hub, text);
1858 }
1859
1860 let config = hub.trust_config().unwrap();
1861 assert_eq!(config.mode, crate::trust::TrustMode::Steady);
1862 assert_eq!(config.theme, crate::trust::Theme::Default);
1863 assert_eq!(config.outside, crate::trust::OutsideBehavior::Approve);
1864 }
1865 }
1866
1867 #[test]
1868 fn trust_config_parses_mode_theme_and_outside() {
1869 let (_dir, hub) = temp_hub();
1870 write_config(
1871 &hub,
1872 "[trust]\nmode = \"eager\"\ntheme = \"weather\"\noutside = \"deny\"\n",
1873 );
1874
1875 let config = hub.trust_config().unwrap();
1876 assert_eq!(config.mode, crate::trust::TrustMode::Eager);
1877 assert_eq!(config.theme, crate::trust::Theme::Weather);
1878 assert_eq!(config.outside, crate::trust::OutsideBehavior::Deny);
1879 }
1880
1881 #[test]
1882 fn trust_config_rejects_invalid_enum() {
1883 let (_dir, hub) = temp_hub();
1884 write_config(&hub, "[trust]\noutside = \"sometimes\"\n");
1885
1886 assert!(matches!(
1887 hub.trust_config(),
1888 Err(ConfigError::Invalid(message)) if message.contains("parse trust config")
1889 ));
1890 }
1891
1892 #[test]
1893 fn preview_config_defaults_when_config_or_section_is_missing() {
1894 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1895 let (_dir, hub) = temp_hub();
1896 if let Some(text) = text {
1897 write_config(&hub, text);
1898 }
1899
1900 let config = hub.preview_config().unwrap();
1901 let expected = crate::tools::preview::PreviewConfig::default();
1902 assert_eq!(config.base_url, expected.base_url);
1903 assert_eq!(config.timeout_ms, expected.timeout_ms);
1904 assert_eq!(config.project_abs_path, expected.project_abs_path);
1905 assert_eq!(config.project_hint_slug, expected.project_hint_slug);
1906 assert_eq!(config.max_body_bytes, expected.max_body_bytes);
1907 }
1908 }
1909
1910 #[test]
1911 fn preview_config_parses_all_supported_fields() {
1912 let (_dir, hub) = temp_hub();
1913 write_config(
1914 &hub,
1915 r#"
1916[preview]
1917base_url = "http://127.0.0.1:9000"
1918timeout_ms = 4500
1919project_abs_path = "/tmp/project"
1920project_hint_slug = "project"
1921max_body_bytes = 2048
1922"#,
1923 );
1924
1925 let config = hub.preview_config().unwrap();
1926 assert_eq!(config.base_url, "http://127.0.0.1:9000");
1927 assert_eq!(config.timeout_ms, 4500);
1928 assert_eq!(config.project_abs_path, "/tmp/project");
1929 assert_eq!(config.project_hint_slug.as_deref(), Some("project"));
1930 assert_eq!(config.max_body_bytes, 2048);
1931 }
1932
1933 #[test]
1934 fn preview_config_rejects_invalid_schema() {
1935 let (_dir, hub) = temp_hub();
1936 write_config(&hub, "[preview]\ntimeout_ms = \"slow\"\n");
1937
1938 assert!(matches!(
1939 hub.preview_config(),
1940 Err(ConfigError::Invalid(message)) if message.contains("parse preview config")
1941 ));
1942 }
1943
1944 #[test]
1945 fn sandbox_config_defaults_when_config_or_section_is_missing() {
1946 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1947 let (_dir, hub) = temp_hub();
1948 if let Some(text) = text {
1949 write_config(&hub, text);
1950 }
1951
1952 assert_eq!(hub.sandbox_config().unwrap(), SandboxConfig::default());
1953 }
1954 }
1955
1956 #[test]
1957 fn sandbox_config_preserves_paths_and_defaults_missing_enabled() {
1958 let (_dir, hub) = temp_hub();
1959 write_config(
1960 &hub,
1961 r#"
1962[sandbox]
1963strict = true
1964extra_read = ["../read"]
1965extra_write = ["/tmp/write"]
1966template_path = "profiles/custom.sb"
1967allow_network = true
1968"#,
1969 );
1970
1971 assert_eq!(
1972 hub.sandbox_config().unwrap(),
1973 SandboxConfig {
1974 enabled: true,
1975 strict: true,
1976 extra_read: vec![PathBuf::from("../read")],
1977 extra_write: vec![PathBuf::from("/tmp/write")],
1978 template_path: Some(PathBuf::from("profiles/custom.sb")),
1979 allow_network: true,
1980 }
1981 );
1982 }
1983
1984 #[test]
1985 fn sandbox_config_allows_explicit_opt_out() {
1986 let (_dir, hub) = temp_hub();
1987 write_config(&hub, "[sandbox]\nenabled = false\n");
1988
1989 assert!(!hub.sandbox_config().unwrap().enabled);
1990 }
1991
1992 #[test]
1993 fn sandbox_config_rejects_invalid_schema() {
1994 let (_dir, hub) = temp_hub();
1995 write_config(&hub, "[sandbox]\nextra_read = \"/tmp\"\n");
1996
1997 assert!(matches!(
1998 hub.sandbox_config(),
1999 Err(ConfigError::Invalid(message)) if message.contains("parse sandbox config")
2000 ));
2001 }
2002
2003 #[test]
2004 fn redact_config_defaults_when_config_is_missing_or_section_is_missing() {
2005 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
2006 let (_dir, hub) = temp_hub();
2007 if let Some(text) = text {
2008 write_config(&hub, text);
2009 }
2010
2011 assert_eq!(hub.redact_config().unwrap(), RedactConfig::default());
2012 }
2013 }
2014
2015 #[test]
2016 fn redact_config_parses_mode_patterns_and_allowlist() {
2017 let (_dir, hub) = temp_hub();
2018 write_config(
2019 &hub,
2020 r#"
2021[redact]
2022enabled = true
2023mode = "partial"
2024allowlist = ["safe@example.com"]
2025custom_patterns = [{ kind = "ticket", regex = "T-[0-9]+" }]
2026"#,
2027 );
2028
2029 assert_eq!(
2030 hub.redact_config().unwrap(),
2031 RedactConfig {
2032 enabled: true,
2033 partial: true,
2034 allowlist: vec!["safe@example.com".into()],
2035 custom_patterns: vec![("ticket".into(), "T-[0-9]+".into())],
2036 }
2037 );
2038 }
2039
2040 #[test]
2041 fn redact_config_treats_unknown_mode_as_full() {
2042 let (_dir, hub) = temp_hub();
2043 write_config(&hub, "[redact]\nenabled = true\nmode = \"unknown\"\n");
2044
2045 let config = hub.redact_config().unwrap();
2046 assert!(config.enabled);
2047 assert!(!config.partial);
2048 }
2049
2050 #[test]
2051 fn redact_config_rejects_invalid_schema() {
2052 let (_dir, hub) = temp_hub();
2053 write_config(&hub, "[redact]\nenabled = \"yes\"\n");
2054
2055 assert!(matches!(
2056 hub.redact_config(),
2057 Err(ConfigError::Invalid(message)) if message.contains("parse redact config")
2058 ));
2059 }
2060
2061 #[test]
2062 fn interjection_mode_defaults_to_none_when_config_or_value_is_missing() {
2063 for text in [
2064 None,
2065 Some("[theme]\nmode = \"dark\"\n"),
2066 Some("[interjection]\n"),
2067 ] {
2068 let (_dir, hub) = temp_hub();
2069 if let Some(text) = text {
2070 write_config(&hub, text);
2071 }
2072
2073 assert_eq!(hub.interjection_mode().unwrap(), None);
2074 }
2075 }
2076
2077 #[test]
2078 fn interjection_mode_parses_supported_and_unknown_values() {
2079 for (value, expected) in [
2080 ("off", InterjectionMode::Off),
2081 ("rule", InterjectionMode::Rule),
2082 ("llm", InterjectionMode::Llm),
2083 ("custom", InterjectionMode::Unknown("custom".into())),
2084 ] {
2085 let (_dir, hub) = temp_hub();
2086 write_config(&hub, &format!("[interjection]\nclassifier = {value:?}\n"));
2087
2088 assert_eq!(hub.interjection_mode().unwrap(), Some(expected));
2089 }
2090 }
2091
2092 #[test]
2093 fn interjection_mode_rejects_non_string_value() {
2094 let (_dir, hub) = temp_hub();
2095 write_config(&hub, "[interjection]\nclassifier = true\n");
2096
2097 assert!(matches!(
2098 hub.interjection_mode(),
2099 Err(ConfigError::Invalid(message)) if message.contains("interjection.classifier")
2100 ));
2101 }
2102
2103 #[test]
2104 fn suggest_model_defaults_to_none_when_config_or_value_is_missing() {
2105 for text in [
2106 None,
2107 Some("[theme]\nmode = \"dark\"\n"),
2108 Some("[suggest]\n"),
2109 ] {
2110 let (_dir, hub) = temp_hub();
2111 if let Some(text) = text {
2112 write_config(&hub, text);
2113 }
2114
2115 assert_eq!(hub.suggest_model().unwrap(), None);
2116 }
2117 }
2118
2119 #[test]
2120 fn suggest_model_returns_configured_string_including_empty() {
2121 for value in ["smart", ""] {
2122 let (_dir, hub) = temp_hub();
2123 write_config(&hub, &format!("[suggest]\nmodel = {value:?}\n"));
2124
2125 assert_eq!(hub.suggest_model().unwrap().as_deref(), Some(value));
2126 }
2127 }
2128
2129 #[test]
2130 fn suggest_model_rejects_non_string_value() {
2131 let (_dir, hub) = temp_hub();
2132 write_config(&hub, "[suggest]\nmodel = 42\n");
2133
2134 assert!(matches!(
2135 hub.suggest_model(),
2136 Err(ConfigError::Invalid(message)) if message.contains("suggest.model")
2137 ));
2138 }
2139
2140 #[test]
2141 fn compact_review_mode_defaults_to_none_when_config_or_value_is_missing() {
2142 for text in [
2143 None,
2144 Some("[theme]\nmode = \"dark\"\n"),
2145 Some("[compaction]\n"),
2146 ] {
2147 let (_dir, hub) = temp_hub();
2148 if let Some(text) = text {
2149 write_config(&hub, text);
2150 }
2151
2152 assert_eq!(hub.compact_review_mode().unwrap(), None);
2153 }
2154 }
2155
2156 #[test]
2157 fn compact_review_mode_parses_supported_values() {
2158 for (value, expected) in [
2159 ("always", crate::CompactReviewMode::Always),
2160 ("manual-only", crate::CompactReviewMode::ManualOnly),
2161 ("manual_only", crate::CompactReviewMode::ManualOnly),
2162 ("never", crate::CompactReviewMode::Never),
2163 ] {
2164 let (_dir, hub) = temp_hub();
2165 write_config(&hub, &format!("[compaction]\nreview = {value:?}\n"));
2166
2167 assert_eq!(hub.compact_review_mode().unwrap(), Some(expected));
2168 }
2169 }
2170
2171 #[test]
2172 fn compact_review_mode_rejects_unknown_or_non_string_value() {
2173 for value in ["\"sometimes\"", "true"] {
2174 let (_dir, hub) = temp_hub();
2175 write_config(&hub, &format!("[compaction]\nreview = {value}\n"));
2176
2177 assert!(matches!(
2178 hub.compact_review_mode(),
2179 Err(ConfigError::Invalid(message)) if message.contains("compaction.review")
2180 ));
2181 }
2182 }
2183
2184 #[test]
2185 fn auto_snapshot_defaults_to_none_when_config_or_value_is_missing() {
2186 for text in [
2187 None,
2188 Some("[theme]\nmode = \"dark\"\n"),
2189 Some("[registry]\n"),
2190 ] {
2191 let (_dir, hub) = temp_hub();
2192 if let Some(text) = text {
2193 write_config(&hub, text);
2194 }
2195
2196 assert_eq!(hub.auto_snapshot().unwrap(), None);
2197 }
2198 }
2199
2200 #[test]
2201 fn auto_snapshot_reads_boolean_values() {
2202 for value in [true, false] {
2203 let (_dir, hub) = temp_hub();
2204 write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2205
2206 assert_eq!(hub.auto_snapshot().unwrap(), Some(value));
2207 }
2208 }
2209
2210 #[test]
2211 fn auto_snapshot_reads_integer_values() {
2212 for (value, expected) in [(1, true), (0, false)] {
2213 let (_dir, hub) = temp_hub();
2214 write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2215
2216 assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2217 }
2218 }
2219
2220 #[test]
2221 fn auto_snapshot_only_enables_exact_true_string() {
2222 for (value, expected) in [("true", true), ("yes", false)] {
2223 let (_dir, hub) = temp_hub();
2224 write_config(&hub, &format!("[registry]\nauto_snapshot = {value:?}\n"));
2225
2226 assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2227 }
2228 }
2229
2230 #[test]
2231 fn auto_snapshot_rejects_unsupported_type() {
2232 let (_dir, hub) = temp_hub();
2233 write_config(&hub, "[registry]\nauto_snapshot = [true]\n");
2234
2235 assert!(matches!(
2236 hub.auto_snapshot(),
2237 Err(ConfigError::Invalid(message)) if message.contains("registry.auto_snapshot")
2238 ));
2239 }
2240
2241 #[test]
2242 fn fs_access_mode_defaults_to_none_when_config_is_missing() {
2243 let (_dir, hub) = temp_hub();
2244
2245 assert_eq!(hub.fs_access_mode().unwrap(), None);
2246 }
2247
2248 #[test]
2249 fn fs_access_mode_defaults_to_none_when_section_or_mode_is_missing() {
2250 for text in ["[theme]\nmode = \"dark\"\n", "[fs_access]\n"] {
2251 let (_dir, hub) = temp_hub();
2252 write_config(&hub, text);
2253
2254 assert_eq!(hub.fs_access_mode().unwrap(), None);
2255 }
2256 }
2257
2258 #[test]
2259 fn fs_access_mode_parses_canonical_and_alias_values() {
2260 for (mode, expected) in [
2261 ("read-only", crate::fs_access::FsAccessMode::ReadOnly),
2262 ("ws", crate::fs_access::FsAccessMode::WorkspaceWrite),
2263 (
2264 "danger-full-access",
2265 crate::fs_access::FsAccessMode::DangerFullAccess,
2266 ),
2267 ] {
2268 let (_dir, hub) = temp_hub();
2269 write_config(&hub, &format!("[fs_access]\nmode = {mode:?}\n"));
2270
2271 assert_eq!(hub.fs_access_mode().unwrap(), Some(expected));
2272 }
2273 }
2274
2275 #[test]
2276 fn fs_access_mode_rejects_unknown_mode() {
2277 let (_dir, hub) = temp_hub();
2278 write_config(&hub, "[fs_access]\nmode = \"chaos\"\n");
2279
2280 assert!(matches!(
2281 hub.fs_access_mode(),
2282 Err(ConfigError::Invalid(message)) if message.contains("unknown fs access mode")
2283 ));
2284 }
2285
2286 #[test]
2287 fn fs_access_mode_rejects_non_string_mode() {
2288 let (_dir, hub) = temp_hub();
2289 write_config(&hub, "[fs_access]\nmode = true\n");
2290
2291 assert!(matches!(
2292 hub.fs_access_mode(),
2293 Err(ConfigError::Invalid(message)) if message.contains("fs_access.mode")
2294 ));
2295 }
2296
2297 fn model<'a>(
2298 old_name: Option<&'a str>,
2299 name: &'a str,
2300 model: &'a str,
2301 ) -> ModelConfigUpdate<'a> {
2302 ModelConfigUpdate {
2303 old_name,
2304 name,
2305 model,
2306 provider: Some("test"),
2307 context_budget: 100_000,
2308 thinking: false,
2309 max_tokens: None,
2310 enabled: true,
2311 }
2312 }
2313
2314 #[test]
2315 fn model_migration_preserves_existing_provider_name() {
2316 let (_dir, hub) = temp_hub();
2317 write_config(
2318 &hub,
2319 r#"[providers.openai]
2320kind = "openai"
2321api_key = "existing"
2322
2323[models.legacy]
2324model = "gpt"
2325provider = "openai"
2326api_key = "legacy"
2327"#,
2328 );
2329
2330 let outcome = hub.migrate_model_config_if_needed().unwrap();
2331 assert!(matches!(
2332 outcome,
2333 crate::model_registry::ModelMigrationOutcome::Migrated { .. }
2334 ));
2335 let text = hub.read_config_toml().unwrap();
2336 assert!(text.contains("[providers.openai]"));
2337 assert!(text.contains("api_key = \"existing\""));
2338 assert!(text.contains("[providers.openai-2]"));
2339 assert!(text.contains("provider = \"openai-2\""));
2340 }
2341
2342 #[test]
2343 fn model_migration_preserves_unversioned_provider_reference() {
2344 let (dir, hub) = temp_hub();
2345 let text = r#"[providers.openai]
2346kind = "openai"
2347api_key = "existing"
2348
2349[models.current]
2350model = "gpt"
2351provider = "openai"
2352"#;
2353 write_config(&hub, text);
2354
2355 assert_eq!(
2356 hub.migrate_model_config_if_needed().unwrap(),
2357 crate::model_registry::ModelMigrationOutcome::NotNeeded
2358 );
2359 assert_eq!(hub.read_config_toml().unwrap(), text);
2360 assert!(!dir.path().join("config.toml.bak").exists());
2361 }
2362
2363 #[test]
2364 fn model_migration_rejects_invalid_and_future_versions() {
2365 for version in ["\"2\"", "3"] {
2366 let (_dir, hub) = temp_hub();
2367 let text = format!(
2368 "config_version = {version}\n[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n"
2369 );
2370 write_config(&hub, &text);
2371 assert!(hub.migrate_model_config_if_needed().is_err());
2372 assert_eq!(hub.read_config_toml().unwrap(), text);
2373 }
2374 }
2375
2376 #[test]
2377 fn model_migration_backup_conflict_preserves_source() {
2378 let (dir, hub) = temp_hub();
2379 let text = "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n";
2380 write_config(&hub, text);
2381 std::fs::write(dir.path().join("config.toml.bak"), "older backup").unwrap();
2382
2383 assert!(matches!(
2384 hub.migrate_model_config_if_needed(),
2385 Err(ConfigError::Invalid(message)) if message.contains("backup conflict")
2386 ));
2387 assert_eq!(hub.read_config_toml().unwrap(), text);
2388 }
2389
2390 #[cfg(unix)]
2391 #[test]
2392 fn model_migration_backup_is_owner_only() {
2393 use std::os::unix::fs::PermissionsExt;
2394 let (dir, hub) = temp_hub();
2395 write_config(
2396 &hub,
2397 "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\napi_key = \"secret\"\n",
2398 );
2399
2400 hub.migrate_model_config_if_needed().unwrap();
2401
2402 let mode = std::fs::metadata(dir.path().join("config.toml.bak"))
2403 .unwrap()
2404 .permissions()
2405 .mode()
2406 & 0o777;
2407 assert_eq!(mode, 0o600);
2408 }
2409
2410 #[test]
2411 fn duplicate_model_name_is_rejected_without_writing() {
2412 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2413 let (_dir, hub) = temp_hub();
2414 hub.upsert_model(model(None, "shared", "provider/a"))
2415 .unwrap();
2416 let before = hub.read_config_toml().unwrap();
2417
2418 let error = hub
2419 .upsert_model(model(None, "shared", "provider/b"))
2420 .unwrap_err();
2421
2422 assert!(matches!(
2423 error,
2424 ConfigError::NameConflict {
2425 domain: "models",
2426 ..
2427 }
2428 ));
2429 assert_eq!(hub.read_config_toml().unwrap(), before);
2430 }
2431
2432 #[test]
2433 fn model_rename_conflict_is_rejected_without_removing_source() {
2434 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2435 let (_dir, hub) = temp_hub();
2436 hub.upsert_model(model(None, "first", "provider/a"))
2437 .unwrap();
2438 hub.upsert_model(model(None, "second", "provider/b"))
2439 .unwrap();
2440 let before = hub.read_config_toml().unwrap();
2441
2442 let error = hub
2443 .upsert_model(model(Some("first"), "second", "provider/a"))
2444 .unwrap_err();
2445
2446 assert!(matches!(error, ConfigError::NameConflict { .. }));
2447 assert_eq!(hub.read_config_toml().unwrap(), before);
2448 }
2449
2450 #[test]
2451 fn model_and_alias_share_a_namespace() {
2452 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2453 let (_dir, hub) = temp_hub();
2454 hub.upsert_model(model(None, "smart", "provider/a"))
2455 .unwrap();
2456 assert!(matches!(
2457 hub.add_alias("smart", "provider/a"),
2458 Err(ConfigError::NameConflict {
2459 domain: "models",
2460 ..
2461 })
2462 ));
2463
2464 hub.add_alias("cheap", "provider/a").unwrap();
2465 assert!(matches!(
2466 hub.upsert_model(model(None, "cheap", "provider/b")),
2467 Err(ConfigError::NameConflict {
2468 domain: "alias",
2469 ..
2470 })
2471 ));
2472 }
2473
2474 #[test]
2475 fn alias_rename_conflict_is_rejected_without_removing_source() {
2476 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2477 let (_dir, hub) = temp_hub();
2478 hub.add_alias("first", "provider/a").unwrap();
2479 hub.add_alias("second", "provider/b").unwrap();
2480 let before = hub.read_config_toml().unwrap();
2481
2482 let error = hub
2483 .update_alias(Some("first"), "second", "provider/a")
2484 .unwrap_err();
2485
2486 assert!(matches!(
2487 error,
2488 ConfigError::NameConflict {
2489 domain: "alias",
2490 ..
2491 }
2492 ));
2493 assert_eq!(hub.read_config_toml().unwrap(), before);
2494 }
2495
2496 #[test]
2497 fn bind_default_model_rebinds_smart_atomically_and_keeps_cheap() {
2498 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2499 let (_dir, hub) = temp_hub();
2500 hub.add_alias("smart", "provider/old").unwrap();
2501
2502 hub.bind_default_model("provider/new").unwrap();
2503
2504 let text = hub.read_config_toml().unwrap();
2505 assert!(text.contains("[alias.smart]"));
2506 assert!(text.contains("model = \"provider/new\""));
2507 assert!(text.contains("[alias.cheap]"));
2508 assert!(text.contains("model = \"smart\""));
2509 }
2510
2511 #[test]
2512 fn bind_default_model_preserves_existing_cheap_alias() {
2513 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2514 let (_dir, hub) = temp_hub();
2515 hub.add_alias("smart", "provider/old").unwrap();
2516 hub.add_alias("cheap", "provider/custom-cheap").unwrap();
2517
2518 hub.bind_default_model("provider/new").unwrap();
2519
2520 let cfg = crate::model_registry::parse_config(&hub.read_config_toml().unwrap()).unwrap();
2521 assert_eq!(cfg.aliases["smart"].model, "provider/new");
2522 assert_eq!(cfg.aliases["cheap"].model, "provider/custom-cheap");
2523 }
2524
2525 #[test]
2526 fn bind_default_model_does_not_overwrite_smart_model() {
2527 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2528 let (_dir, hub) = temp_hub();
2529 hub.upsert_model(model(None, "smart", "provider/model"))
2530 .unwrap();
2531 let before = hub.read_config_toml().unwrap();
2532
2533 assert!(matches!(
2534 hub.bind_default_model("provider/new"),
2535 Err(ConfigError::NameConflict {
2536 domain: "models",
2537 ..
2538 })
2539 ));
2540 assert_eq!(hub.read_config_toml().unwrap(), before);
2541 }
2542
2543 #[test]
2544 fn distinct_names_may_use_the_same_provider_model_id() {
2545 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2546 let (_dir, hub) = temp_hub();
2547 hub.upsert_model(model(None, "first", "provider/shared"))
2548 .unwrap();
2549 hub.upsert_model(model(None, "second", "provider/shared"))
2550 .unwrap();
2551
2552 let text = hub.read_config_toml().unwrap();
2553 assert!(text.contains("[models.first]"));
2554 assert!(text.contains("[models.second]"));
2555 }
2556
2557 #[test]
2558 fn mcp_upsert_preserves_existing_json_servers_and_overrides_toml_by_name() {
2559 let (_dir, hub) = temp_hub();
2560 std::fs::write(
2561 hub.config_toml_path(),
2562 "[[mcp]]\nname = \"shared\"\ncommand = \"from-toml\"\n",
2563 )
2564 .unwrap();
2565 hub.save_mcp(&[crate::mcp::McpServerConfig::stdio(
2566 "existing",
2567 "existing-command",
2568 vec![],
2569 crate::tool::Tier::Two,
2570 30_000,
2571 )])
2572 .unwrap();
2573
2574 hub.upsert_mcp(crate::mcp::McpServerConfig::stdio(
2575 "shared",
2576 "from-json",
2577 vec![],
2578 crate::tool::Tier::Three,
2579 30_000,
2580 ))
2581 .unwrap();
2582
2583 let configs = hub.load_local_mcp();
2584 assert_eq!(configs.len(), 2);
2585 assert_eq!(
2586 configs
2587 .iter()
2588 .find(|cfg| cfg.name == "shared")
2589 .unwrap()
2590 .command,
2591 "from-json"
2592 );
2593 assert!(configs.iter().any(|cfg| cfg.name == "existing"));
2594 assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2595 }
2596
2597 #[test]
2598 fn mcp_toggle_toml_server_persists_json_override() {
2599 let (_dir, hub) = temp_hub();
2600 std::fs::write(
2601 hub.config_toml_path(),
2602 "[[mcp]]\nname = \"exa\"\ncommand = \"exa-mcp-server\"\n",
2603 )
2604 .unwrap();
2605
2606 assert!(hub.toggle_mcp("exa").unwrap());
2607
2608 let configs = hub.load_local_mcp();
2609 assert!(
2610 configs
2611 .iter()
2612 .find(|cfg| cfg.name == "exa")
2613 .unwrap()
2614 .disabled
2615 );
2616 assert!(hub.mcp_json_path().exists());
2617 }
2618
2619 #[test]
2620 fn mcp_remove_updates_json_atomically() {
2621 let (_dir, hub) = temp_hub();
2622 hub.save_mcp(&[
2623 crate::mcp::McpServerConfig::stdio(
2624 "first",
2625 "echo",
2626 vec![],
2627 crate::tool::Tier::Two,
2628 30_000,
2629 ),
2630 crate::mcp::McpServerConfig::stdio(
2631 "second",
2632 "ls",
2633 vec![],
2634 crate::tool::Tier::Two,
2635 30_000,
2636 ),
2637 ])
2638 .unwrap();
2639
2640 hub.remove_mcp("first").unwrap();
2641
2642 let configs = hub.load_local_mcp();
2643 assert_eq!(configs.len(), 1);
2644 assert_eq!(configs[0].name, "second");
2645 assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2646 }
2647
2648 #[test]
2649 fn alias_updates_preserve_comments_and_other_sections() {
2650 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2651 let (_dir, hub) = temp_hub();
2652 std::fs::write(
2653 hub.config_toml_path(),
2654 "# keep me\n[theme]\nname = \"dark\"\n\n[alias.old]\nmodel = \"provider/a\"\n",
2655 )
2656 .unwrap();
2657
2658 hub.update_alias(Some("old"), "new", "provider/b").unwrap();
2659
2660 let text = hub.read_config_toml().unwrap();
2661 assert!(text.contains("# keep me"));
2662 assert!(text.contains("[theme]"));
2663 assert!(text.contains("[alias.new]"));
2664 assert!(!text.contains("[alias.old]"));
2665 }
2666}