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