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 {
1345 max_lines: 256,
1346 max_bytes: 10 * 1024,
1347 max_line_bytes: 10 * 1024,
1348 }
1349 );
1350 write_config(
1351 &hub,
1352 "[tool_output]\nmax_lines = 7\nmax_bytes = 777\nmax_line_bytes = 111\n",
1353 );
1354 assert_eq!(
1355 hub.tool_output_budget().unwrap(),
1356 crate::tools::tool_output::ToolOutputBudget {
1357 max_lines: 7,
1358 max_bytes: 777,
1359 max_line_bytes: 111,
1360 }
1361 );
1362 let _ = dir;
1363 }
1364
1365 #[test]
1366 fn tool_output_budget_rejects_zero_values() {
1367 let (_dir, hub) = temp_hub();
1368 write_config(&hub, "[tool_output]\nmax_bytes = 0\n");
1369 assert!(hub.tool_output_budget().is_err());
1370 }
1371
1372 #[test]
1373 fn storage_config_merges_only_typed_storage_projection() {
1374 let (dir, hub) = temp_hub();
1375 std::fs::write(
1376 dir.path().join("config.toml"),
1377 "[storage]\nscope = \"local\"\n[theme]\nmode = \"dark\"\n",
1378 )
1379 .unwrap();
1380 let project = tempfile::tempdir().unwrap();
1381 std::fs::create_dir(project.path().join(".atman")).unwrap();
1382 std::fs::write(
1383 project.path().join(".atman/config.toml"),
1384 "[storage]\nscope = \"global\"\n[theme]\nmode = \"light\"\n",
1385 )
1386 .unwrap();
1387
1388 assert_eq!(
1389 hub.storage_config(Some(project.path())).scope,
1390 Some(crate::storage::StorageScope::Global)
1391 );
1392 }
1393
1394 #[test]
1395 fn storage_config_isolates_invalid_global_and_project_layers() {
1396 let (dir, hub) = temp_hub();
1397 let project = tempfile::tempdir().unwrap();
1398 std::fs::create_dir(project.path().join(".atman")).unwrap();
1399 std::fs::write(dir.path().join("config.toml"), "not valid [").unwrap();
1400 std::fs::write(
1401 project.path().join(".atman/config.toml"),
1402 "[storage]\nscope = \"local\"\n",
1403 )
1404 .unwrap();
1405 assert_eq!(
1406 hub.storage_config(Some(project.path())).scope,
1407 Some(crate::storage::StorageScope::Local)
1408 );
1409
1410 std::fs::write(
1411 dir.path().join("config.toml"),
1412 "[storage]\nscope = \"global\"\n",
1413 )
1414 .unwrap();
1415 std::fs::write(project.path().join(".atman/config.toml"), "not valid [").unwrap();
1416 assert_eq!(
1417 hub.storage_config(Some(project.path())).scope,
1418 Some(crate::storage::StorageScope::Global)
1419 );
1420 }
1421
1422 #[test]
1423 fn storage_config_treats_read_errors_as_empty_layers() {
1424 let (dir, hub) = temp_hub();
1425 std::fs::create_dir(dir.path().join("config.toml")).unwrap();
1426 let project = tempfile::tempdir().unwrap();
1427 std::fs::create_dir(project.path().join(".atman")).unwrap();
1428 std::fs::write(
1429 project.path().join(".atman/config.toml"),
1430 "[storage]\nscope = \"local\"\n",
1431 )
1432 .unwrap();
1433
1434 assert_eq!(
1435 hub.storage_config(Some(project.path())).scope,
1436 Some(crate::storage::StorageScope::Local)
1437 );
1438 }
1439
1440 fn write_config(hub: &ConfigHub, text: &str) {
1441 std::fs::write(hub.config_toml_path(), text).unwrap();
1442 }
1443
1444 #[test]
1445 fn append_dsl_route_creates_missing_file_and_escapes_trigger() {
1446 let (_dir, hub) = temp_hub();
1447 hub.append_dsl_route("review_code", "say \"hi\"\\now\n")
1448 .unwrap();
1449
1450 let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1451 let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1452 assert_eq!(parsed.routes.len(), 1);
1453 assert_eq!(parsed.routes[0].pattern, "say \"hi\"\\now\n");
1454 assert_eq!(parsed.routes[0].flow.name, "review_code");
1455 }
1456
1457 #[test]
1458 fn append_dsl_route_preserves_existing_source_exactly() {
1459 let (_dir, hub) = temp_hub();
1460 let original = "// keep this comment\nroute \"old \" { flow: old_flow }";
1461 std::fs::write(hub.routes_at_path(), original).unwrap();
1462
1463 hub.append_dsl_route("new_flow", "new ").unwrap();
1464
1465 assert_eq!(
1466 std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1467 format!("{original}\nroute \"new \" {{ flow: new_flow }}\n")
1468 );
1469 }
1470
1471 #[test]
1472 fn append_dsl_route_does_not_overwrite_invalid_existing_source() {
1473 let (_dir, hub) = temp_hub();
1474 let invalid = "route invalid";
1475 std::fs::write(hub.routes_at_path(), invalid).unwrap();
1476
1477 let error = hub.append_dsl_route("new_flow", "new ").unwrap_err();
1478
1479 assert!(error.to_string().contains("parse existing routes.at"));
1480 assert_eq!(
1481 std::fs::read_to_string(hub.routes_at_path()).unwrap(),
1482 invalid
1483 );
1484 }
1485
1486 #[test]
1487 fn append_dsl_route_rejects_invalid_flow_without_writing() {
1488 let (_dir, hub) = temp_hub();
1489 let error = hub.append_dsl_route("bad-name", "new ").unwrap_err();
1490 assert!(error.to_string().contains("valid DSL identifier"));
1491 assert!(!hub.routes_at_path().exists());
1492 }
1493
1494 #[test]
1495 fn concurrent_dsl_route_appends_do_not_lose_updates() {
1496 let (_dir, hub) = temp_hub();
1497 let mut workers = Vec::new();
1498 for index in 0..12 {
1499 let hub = hub.clone();
1500 workers.push(std::thread::spawn(move || {
1501 hub.append_dsl_route(&format!("flow_{index}"), &format!("{index} "))
1502 .unwrap();
1503 }));
1504 }
1505 for worker in workers {
1506 worker.join().unwrap();
1507 }
1508
1509 let source = std::fs::read_to_string(hub.routes_at_path()).unwrap();
1510 let parsed = atman_dsl::parse::parse_file(&source).unwrap();
1511 assert_eq!(parsed.routes.len(), 12);
1512 for index in 0..12 {
1513 assert!(parsed.routes.iter().any(|route| {
1514 route.flow.name == format!("flow_{index}") && route.pattern == format!("{index} ")
1515 }));
1516 }
1517 assert!(!std::fs::read_dir(hub.config_dir()).unwrap().any(|entry| {
1518 let name = entry.unwrap().file_name();
1519 let name = name.to_string_lossy();
1520 name.starts_with(".routes.at.") && name.ends_with(".tmp")
1521 }));
1522 }
1523
1524 #[test]
1525 fn append_dsl_route_waits_for_external_file_lock() {
1526 use fs2::FileExt;
1527 use std::sync::mpsc::TryRecvError;
1528
1529 let (_dir, hub) = temp_hub();
1530 let lock = std::fs::OpenOptions::new()
1531 .read(true)
1532 .write(true)
1533 .create(true)
1534 .truncate(false)
1535 .open(hub.config_dir().join(".routes.at.lock"))
1536 .unwrap();
1537 lock.lock_exclusive().unwrap();
1538
1539 let worker_hub = hub.clone();
1540 let (tx, rx) = std::sync::mpsc::channel();
1541 let worker = std::thread::spawn(move || {
1542 worker_hub.append_dsl_route("blocked", "wait ").unwrap();
1543 tx.send(()).unwrap();
1544 });
1545 assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1546 FileExt::unlock(&lock).unwrap();
1547 rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1548 worker.join().unwrap();
1549 }
1550
1551 #[test]
1552 fn model_config_projection_handles_missing_valid_and_invalid_files() {
1553 let (_dir, hub) = temp_hub();
1554 assert!(hub.model_config().unwrap().is_none());
1555
1556 write_config(
1557 &hub,
1558 "[providers.openai]\nkind = \"openai\"\n[models.fast]\nmodel = \"gpt-4o-mini\"\n[alias.default]\nmodel = \"fast\"\n",
1559 );
1560 let config = hub.model_config().unwrap().unwrap();
1561 assert_eq!(config.providers["openai"].kind, "openai");
1562 assert_eq!(config.models["fast"].model, "gpt-4o-mini");
1563 assert_eq!(config.aliases["default"].model, "fast");
1564
1565 write_config(&hub, "[models]\n");
1566 assert!(hub.model_config().unwrap().is_none());
1567
1568 write_config(&hub, "[models\n");
1569 assert!(hub.model_config().is_err());
1570 }
1571
1572 #[test]
1573 fn theme_preference_defaults_to_auto_when_config_is_missing() {
1574 let (_dir, hub) = temp_hub();
1575
1576 assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1577 }
1578
1579 #[test]
1580 fn theme_preference_defaults_to_auto_when_mode_is_missing() {
1581 let (_dir, hub) = temp_hub();
1582 write_config(&hub, "[theme]\n");
1583
1584 assert_eq!(hub.theme_preference().unwrap(), ThemePreference::Auto);
1585 }
1586
1587 #[test]
1588 fn theme_preference_parses_supported_modes() {
1589 for (mode, expected) in [
1590 ("auto", ThemePreference::Auto),
1591 ("light", ThemePreference::Light),
1592 ("LiGhT", ThemePreference::Light),
1593 ("dark", ThemePreference::Dark),
1594 ] {
1595 let (_dir, hub) = temp_hub();
1596 write_config(&hub, &format!("[theme]\nmode = {mode:?}\n"));
1597
1598 assert_eq!(hub.theme_preference().unwrap(), expected);
1599 }
1600 }
1601
1602 #[test]
1603 fn theme_preference_rejects_unknown_mode() {
1604 let (_dir, hub) = temp_hub();
1605 write_config(&hub, "[theme]\nmode = \"sepia\"\n");
1606
1607 assert!(matches!(
1608 hub.theme_preference(),
1609 Err(ConfigError::Invalid(message)) if message.contains("theme.mode")
1610 ));
1611 }
1612
1613 fn auth_provider(id: &str) -> crate::auth_store::StoredProvider {
1614 crate::auth_store::StoredProvider {
1615 id: id.into(),
1616 name: id.into(),
1617 kind: crate::auth_store::ProviderKind::Codex,
1618 access_token: "old-access".into(),
1619 refresh_token: Some("old-refresh".into()),
1620 expires_at: 1,
1621 account: Some("old-account".into()),
1622 enabled: true,
1623 model_cache: None,
1624 }
1625 }
1626
1627 #[test]
1628 fn auth_transactions_preserve_independent_concurrent_updates() {
1629 let dir = tempfile::tempdir().unwrap();
1630 let path = dir.path().join("auth.json");
1631 let hub = ConfigHub::from_auth_path(&path);
1632 hub.add_auth_provider(auth_provider("provider")).unwrap();
1633
1634 let cache_hub = hub.clone();
1635 let cache = std::thread::spawn(move || {
1636 cache_hub
1637 .update_auth_model_cache(
1638 "provider",
1639 crate::auth_store::ModelCache {
1640 fetched_at: 10,
1641 models: vec![crate::auth_store::CachedModel {
1642 slug: "cached-model".into(),
1643 context_budget: Some(8192),
1644 thinking: true,
1645 }],
1646 },
1647 )
1648 .unwrap();
1649 });
1650 let token_hub = hub.clone();
1651 let tokens = std::thread::spawn(move || {
1652 token_hub
1653 .update_auth_tokens(
1654 "provider",
1655 AuthTokenUpdate {
1656 access_token: "new-access".into(),
1657 refresh_token: Some("new-refresh".into()),
1658 expires_at: 99,
1659 account: None,
1660 },
1661 )
1662 .unwrap();
1663 });
1664 let enabled_hub = hub.clone();
1665 let enabled = std::thread::spawn(move || {
1666 enabled_hub
1667 .set_auth_provider_enabled("provider", false)
1668 .unwrap();
1669 });
1670 cache.join().unwrap();
1671 tokens.join().unwrap();
1672 enabled.join().unwrap();
1673
1674 let store = hub.load_auth().unwrap();
1675 let provider = &store.providers[0];
1676 assert_eq!(provider.access_token, "new-access");
1677 assert_eq!(provider.refresh_token.as_deref(), Some("new-refresh"));
1678 assert_eq!(provider.expires_at, 99);
1679 assert_eq!(provider.account.as_deref(), Some("old-account"));
1680 assert!(!provider.enabled);
1681 assert_eq!(
1682 provider.model_cache.as_ref().unwrap().models[0].slug,
1683 "cached-model"
1684 );
1685 #[cfg(unix)]
1686 {
1687 use std::os::unix::fs::PermissionsExt;
1688 assert_eq!(
1689 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1690 0o600
1691 );
1692 }
1693 assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1694 let name = entry.unwrap().file_name();
1695 let name = name.to_string_lossy();
1696 name.starts_with(".auth.json.") && name.ends_with(".tmp")
1697 }));
1698 }
1699
1700 #[test]
1701 fn auth_transaction_waits_for_external_file_lock() {
1702 use fs2::FileExt;
1703 use std::sync::mpsc::TryRecvError;
1704
1705 let dir = tempfile::tempdir().unwrap();
1706 let path = dir.path().join("auth.json");
1707 let lock_path = dir.path().join(".auth.json.lock");
1708 let lock = std::fs::OpenOptions::new()
1709 .read(true)
1710 .write(true)
1711 .create(true)
1712 .truncate(false)
1713 .open(lock_path)
1714 .unwrap();
1715 lock.lock_exclusive().unwrap();
1716
1717 let hub = ConfigHub::from_auth_path(&path);
1718 let (tx, rx) = std::sync::mpsc::channel();
1719 let worker = std::thread::spawn(move || {
1720 hub.add_auth_provider(auth_provider("blocked")).unwrap();
1721 tx.send(()).unwrap();
1722 });
1723 assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1724 FileExt::unlock(&lock).unwrap();
1725 rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
1726 worker.join().unwrap();
1727 }
1728
1729 #[test]
1730 fn auth_transaction_error_rolls_back_without_writing() {
1731 let dir = tempfile::tempdir().unwrap();
1732 let path = dir.path().join("auth.json");
1733 let hub = ConfigHub::from_auth_path(&path);
1734 hub.add_auth_provider(auth_provider("original")).unwrap();
1735 let before = std::fs::read(&path).unwrap();
1736
1737 let result: Result<(), ConfigError> = hub.update_auth(|store| {
1738 store.providers.push(auth_provider("discarded"));
1739 Err(ConfigError::Invalid("reject mutation".into()))
1740 });
1741 assert!(result.is_err());
1742 assert_eq!(std::fs::read(&path).unwrap(), before);
1743 }
1744
1745 #[test]
1746 fn auth_transaction_does_not_overwrite_corrupt_json() {
1747 let dir = tempfile::tempdir().unwrap();
1748 let path = dir.path().join("auth.json");
1749 let corrupt = b"{not-json";
1750 std::fs::write(&path, corrupt).unwrap();
1751 let hub = ConfigHub::from_auth_path(&path);
1752
1753 let err = hub.add_auth_provider(auth_provider("new")).unwrap_err();
1754 assert!(err.to_string().contains("parse"));
1755 assert_eq!(std::fs::read(&path).unwrap(), corrupt);
1756 }
1757
1758 #[test]
1759 fn auth_load_defaults_when_file_is_missing() {
1760 let dir = tempfile::tempdir().unwrap();
1761 let hub = ConfigHub::from_auth_path(dir.path().join("auth.json"));
1762 assert!(hub.load_auth().unwrap().providers.is_empty());
1763 }
1764
1765 #[test]
1766 fn daemon_config_initializes_reuses_and_rotates_atomically() {
1767 let dir = tempfile::tempdir().unwrap();
1768 let path = dir.path().join("daemon.toml");
1769 let hub = ConfigHub::from_daemon_config_path(&path);
1770
1771 let first = hub.load_or_init_daemon_config().unwrap();
1772 assert_eq!(first.auth_token.len(), 64);
1773 assert!(first.auth_token.chars().all(|c| c.is_ascii_hexdigit()));
1774 let second = hub.load_or_init_daemon_config().unwrap();
1775 assert_eq!(second, first);
1776 assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1777 let name = entry.unwrap().file_name();
1778 let name = name.to_string_lossy();
1779 name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1780 }));
1781 #[cfg(unix)]
1782 {
1783 use std::os::unix::fs::PermissionsExt;
1784 assert_eq!(
1785 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1786 0o600
1787 );
1788 }
1789
1790 let rotated = hub.rotate_daemon_config().unwrap();
1791 assert_ne!(rotated.auth_token, first.auth_token);
1792 assert_eq!(hub.load_or_init_daemon_config().unwrap(), rotated);
1793 assert!(!std::fs::read_dir(dir.path()).unwrap().any(|entry| {
1794 let name = entry.unwrap().file_name();
1795 let name = name.to_string_lossy();
1796 name.starts_with(".daemon.toml.") && name.ends_with(".tmp")
1797 }));
1798 }
1799
1800 #[test]
1801 fn daemon_config_waits_for_external_file_lock() {
1802 use std::sync::mpsc::TryRecvError;
1803
1804 let dir = tempfile::tempdir().unwrap();
1805 let path = dir.path().join("custom-daemon.toml");
1806 let lock = lock_file(&lock_path_for(&path)).unwrap();
1807 let hub = ConfigHub::from_daemon_config_path(&path);
1808 let (tx, rx) = std::sync::mpsc::channel();
1809 let worker = std::thread::spawn(move || {
1810 tx.send(hub.load_or_init_daemon_config()).unwrap();
1811 });
1812
1813 std::thread::sleep(std::time::Duration::from_millis(25));
1814 assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
1815 lock.unlock().unwrap();
1816 assert!(
1817 rx.recv_timeout(std::time::Duration::from_secs(1))
1818 .unwrap()
1819 .is_ok()
1820 );
1821 worker.join().unwrap();
1822 }
1823
1824 #[cfg(unix)]
1825 #[test]
1826 fn legacy_daemon_config_uses_custom_path_and_owner_only_permissions() {
1827 use std::os::unix::fs::PermissionsExt;
1828
1829 let config = tempfile::tempdir().unwrap();
1830 let data = tempfile::tempdir().unwrap();
1831 let daemon_path = config.path().join("daemon/custom.toml");
1832 std::fs::write(data.path().join("daemon.toml"), "auth_token = \"legacy\"\n").unwrap();
1833
1834 let report = ConfigHub::from_config_dir(config.path())
1835 .with_daemon_config_path(&daemon_path)
1836 .migrate_legacy_layout(data.path())
1837 .unwrap()
1838 .unwrap();
1839
1840 assert!(report.moved.iter().any(|path| path == "daemon.toml"));
1841 assert_eq!(
1842 std::fs::read_to_string(&daemon_path).unwrap(),
1843 "auth_token = \"legacy\"\n"
1844 );
1845 assert!(!config.path().join("daemon.toml").exists());
1846 assert_eq!(
1847 std::fs::metadata(&daemon_path)
1848 .unwrap()
1849 .permissions()
1850 .mode()
1851 & 0o777,
1852 0o600
1853 );
1854 }
1855
1856 #[test]
1857 fn daemon_config_rotation_requires_existing_file() {
1858 let dir = tempfile::tempdir().unwrap();
1859 let path = dir.path().join("missing.toml");
1860 let err = ConfigHub::from_daemon_config_path(&path)
1861 .rotate_daemon_config()
1862 .unwrap_err();
1863 assert!(err.to_string().contains("no daemon config"));
1864 }
1865
1866 #[test]
1867 fn web_configs_default_when_config_or_section_is_missing() {
1868 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1869 let (_dir, hub) = temp_hub();
1870 if let Some(text) = text {
1871 write_config(&hub, text);
1872 }
1873
1874 let fetch = hub.web_fetch_config().unwrap();
1875 assert_eq!(fetch.max_bytes, 1_000_000);
1876 assert!(fetch.url_allowlist.is_empty());
1877 assert!(fetch.url_denylist.is_empty());
1878 let search = hub.web_search_config().unwrap();
1879 assert_eq!(search.provider_name(), "tavily");
1880 }
1881 }
1882
1883 #[test]
1884 fn web_configs_parse_fetch_and_search_fields() {
1885 let (_dir, hub) = temp_hub();
1886 write_config(
1887 &hub,
1888 r#"
1889[web]
1890max_bytes = 4096
1891url_allowlist = ["https://ok.example"]
1892url_denylist = ["https://ok.example/private"]
1893
1894[web.search]
1895provider = "searxng"
1896base_url = "http://localhost:8080"
1897max_results = 6
1898"#,
1899 );
1900
1901 let fetch = hub.web_fetch_config().unwrap();
1902 assert_eq!(fetch.max_bytes, 4096);
1903 assert_eq!(fetch.url_allowlist, vec!["https://ok.example"]);
1904 assert_eq!(fetch.url_denylist, vec!["https://ok.example/private"]);
1905 assert_eq!(hub.web_search_config().unwrap().provider_name(), "searxng");
1906 }
1907
1908 #[test]
1909 fn web_fetch_schema_error_does_not_break_valid_search() {
1910 let (_dir, hub) = temp_hub();
1911 write_config(
1912 &hub,
1913 "[web]\nmax_bytes = \"large\"\n[web.search]\nprovider = \"none\"\n",
1914 );
1915
1916 assert!(matches!(
1917 hub.web_fetch_config(),
1918 Err(ConfigError::Invalid(_))
1919 ));
1920 assert_eq!(hub.web_search_config().unwrap().provider_name(), "none");
1921 }
1922
1923 #[test]
1924 fn web_search_schema_error_does_not_break_valid_fetch() {
1925 let (_dir, hub) = temp_hub();
1926 write_config(
1927 &hub,
1928 "[web]\nmax_bytes = 2048\n[web.search]\nprovider = \"unknown\"\n",
1929 );
1930
1931 assert_eq!(hub.web_fetch_config().unwrap().max_bytes, 2048);
1932 assert!(matches!(
1933 hub.web_search_config(),
1934 Err(ConfigError::Invalid(_))
1935 ));
1936 }
1937
1938 #[test]
1939 fn trust_config_defaults_when_config_or_section_is_missing() {
1940 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1941 let (_dir, hub) = temp_hub();
1942 if let Some(text) = text {
1943 write_config(&hub, text);
1944 }
1945
1946 let config = hub.trust_config().unwrap();
1947 assert_eq!(config.mode, crate::trust::TrustMode::Steady);
1948 assert_eq!(config.theme, crate::trust::Theme::Default);
1949 assert_eq!(config.outside, crate::trust::OutsideBehavior::Approve);
1950 }
1951 }
1952
1953 #[test]
1954 fn trust_config_parses_mode_theme_and_outside() {
1955 let (_dir, hub) = temp_hub();
1956 write_config(
1957 &hub,
1958 "[trust]\nmode = \"eager\"\ntheme = \"weather\"\noutside = \"deny\"\n",
1959 );
1960
1961 let config = hub.trust_config().unwrap();
1962 assert_eq!(config.mode, crate::trust::TrustMode::Eager);
1963 assert_eq!(config.theme, crate::trust::Theme::Weather);
1964 assert_eq!(config.outside, crate::trust::OutsideBehavior::Deny);
1965 }
1966
1967 #[test]
1968 fn trust_config_rejects_invalid_enum() {
1969 let (_dir, hub) = temp_hub();
1970 write_config(&hub, "[trust]\noutside = \"sometimes\"\n");
1971
1972 assert!(matches!(
1973 hub.trust_config(),
1974 Err(ConfigError::Invalid(message)) if message.contains("parse trust config")
1975 ));
1976 }
1977
1978 #[test]
1979 fn preview_config_defaults_when_config_or_section_is_missing() {
1980 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
1981 let (_dir, hub) = temp_hub();
1982 if let Some(text) = text {
1983 write_config(&hub, text);
1984 }
1985
1986 let config = hub.preview_config().unwrap();
1987 let expected = crate::tools::preview::PreviewConfig::default();
1988 assert_eq!(config.base_url, expected.base_url);
1989 assert_eq!(config.timeout_ms, expected.timeout_ms);
1990 assert_eq!(config.project_abs_path, expected.project_abs_path);
1991 assert_eq!(config.project_hint_slug, expected.project_hint_slug);
1992 assert_eq!(config.max_body_bytes, expected.max_body_bytes);
1993 }
1994 }
1995
1996 #[test]
1997 fn preview_config_parses_all_supported_fields() {
1998 let (_dir, hub) = temp_hub();
1999 write_config(
2000 &hub,
2001 r#"
2002[preview]
2003base_url = "http://127.0.0.1:9000"
2004timeout_ms = 4500
2005project_abs_path = "/tmp/project"
2006project_hint_slug = "project"
2007max_body_bytes = 2048
2008"#,
2009 );
2010
2011 let config = hub.preview_config().unwrap();
2012 assert_eq!(config.base_url, "http://127.0.0.1:9000");
2013 assert_eq!(config.timeout_ms, 4500);
2014 assert_eq!(config.project_abs_path, "/tmp/project");
2015 assert_eq!(config.project_hint_slug.as_deref(), Some("project"));
2016 assert_eq!(config.max_body_bytes, 2048);
2017 }
2018
2019 #[test]
2020 fn preview_config_rejects_invalid_schema() {
2021 let (_dir, hub) = temp_hub();
2022 write_config(&hub, "[preview]\ntimeout_ms = \"slow\"\n");
2023
2024 assert!(matches!(
2025 hub.preview_config(),
2026 Err(ConfigError::Invalid(message)) if message.contains("parse preview config")
2027 ));
2028 }
2029
2030 #[test]
2031 fn sandbox_config_defaults_when_config_or_section_is_missing() {
2032 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
2033 let (_dir, hub) = temp_hub();
2034 if let Some(text) = text {
2035 write_config(&hub, text);
2036 }
2037
2038 assert_eq!(hub.sandbox_config().unwrap(), SandboxConfig::default());
2039 }
2040 }
2041
2042 #[test]
2043 fn sandbox_config_preserves_paths_and_defaults_missing_enabled() {
2044 let (_dir, hub) = temp_hub();
2045 write_config(
2046 &hub,
2047 r#"
2048[sandbox]
2049strict = true
2050extra_read = ["../read"]
2051extra_write = ["/tmp/write"]
2052template_path = "profiles/custom.sb"
2053allow_network = true
2054"#,
2055 );
2056
2057 assert_eq!(
2058 hub.sandbox_config().unwrap(),
2059 SandboxConfig {
2060 enabled: true,
2061 strict: true,
2062 extra_read: vec![PathBuf::from("../read")],
2063 extra_write: vec![PathBuf::from("/tmp/write")],
2064 template_path: Some(PathBuf::from("profiles/custom.sb")),
2065 allow_network: true,
2066 }
2067 );
2068 }
2069
2070 #[test]
2071 fn sandbox_config_allows_explicit_opt_out() {
2072 let (_dir, hub) = temp_hub();
2073 write_config(&hub, "[sandbox]\nenabled = false\n");
2074
2075 assert!(!hub.sandbox_config().unwrap().enabled);
2076 }
2077
2078 #[test]
2079 fn sandbox_config_rejects_invalid_schema() {
2080 let (_dir, hub) = temp_hub();
2081 write_config(&hub, "[sandbox]\nextra_read = \"/tmp\"\n");
2082
2083 assert!(matches!(
2084 hub.sandbox_config(),
2085 Err(ConfigError::Invalid(message)) if message.contains("parse sandbox config")
2086 ));
2087 }
2088
2089 #[test]
2090 fn redact_config_defaults_when_config_is_missing_or_section_is_missing() {
2091 for text in [None, Some("[theme]\nmode = \"dark\"\n")] {
2092 let (_dir, hub) = temp_hub();
2093 if let Some(text) = text {
2094 write_config(&hub, text);
2095 }
2096
2097 assert_eq!(hub.redact_config().unwrap(), RedactConfig::default());
2098 }
2099 }
2100
2101 #[test]
2102 fn redact_config_parses_mode_patterns_and_allowlist() {
2103 let (_dir, hub) = temp_hub();
2104 write_config(
2105 &hub,
2106 r#"
2107[redact]
2108enabled = true
2109mode = "partial"
2110allowlist = ["safe@example.com"]
2111custom_patterns = [{ kind = "ticket", regex = "T-[0-9]+" }]
2112"#,
2113 );
2114
2115 assert_eq!(
2116 hub.redact_config().unwrap(),
2117 RedactConfig {
2118 enabled: true,
2119 partial: true,
2120 allowlist: vec!["safe@example.com".into()],
2121 custom_patterns: vec![("ticket".into(), "T-[0-9]+".into())],
2122 }
2123 );
2124 }
2125
2126 #[test]
2127 fn redact_config_treats_unknown_mode_as_full() {
2128 let (_dir, hub) = temp_hub();
2129 write_config(&hub, "[redact]\nenabled = true\nmode = \"unknown\"\n");
2130
2131 let config = hub.redact_config().unwrap();
2132 assert!(config.enabled);
2133 assert!(!config.partial);
2134 }
2135
2136 #[test]
2137 fn redact_config_rejects_invalid_schema() {
2138 let (_dir, hub) = temp_hub();
2139 write_config(&hub, "[redact]\nenabled = \"yes\"\n");
2140
2141 assert!(matches!(
2142 hub.redact_config(),
2143 Err(ConfigError::Invalid(message)) if message.contains("parse redact config")
2144 ));
2145 }
2146
2147 #[test]
2148 fn interjection_mode_defaults_to_none_when_config_or_value_is_missing() {
2149 for text in [
2150 None,
2151 Some("[theme]\nmode = \"dark\"\n"),
2152 Some("[interjection]\n"),
2153 ] {
2154 let (_dir, hub) = temp_hub();
2155 if let Some(text) = text {
2156 write_config(&hub, text);
2157 }
2158
2159 assert_eq!(hub.interjection_mode().unwrap(), None);
2160 }
2161 }
2162
2163 #[test]
2164 fn interjection_mode_parses_supported_and_unknown_values() {
2165 for (value, expected) in [
2166 ("off", InterjectionMode::Off),
2167 ("rule", InterjectionMode::Rule),
2168 ("llm", InterjectionMode::Llm),
2169 ("custom", InterjectionMode::Unknown("custom".into())),
2170 ] {
2171 let (_dir, hub) = temp_hub();
2172 write_config(&hub, &format!("[interjection]\nclassifier = {value:?}\n"));
2173
2174 assert_eq!(hub.interjection_mode().unwrap(), Some(expected));
2175 }
2176 }
2177
2178 #[test]
2179 fn interjection_mode_rejects_non_string_value() {
2180 let (_dir, hub) = temp_hub();
2181 write_config(&hub, "[interjection]\nclassifier = true\n");
2182
2183 assert!(matches!(
2184 hub.interjection_mode(),
2185 Err(ConfigError::Invalid(message)) if message.contains("interjection.classifier")
2186 ));
2187 }
2188
2189 #[test]
2190 fn suggest_model_defaults_to_none_when_config_or_value_is_missing() {
2191 for text in [
2192 None,
2193 Some("[theme]\nmode = \"dark\"\n"),
2194 Some("[suggest]\n"),
2195 ] {
2196 let (_dir, hub) = temp_hub();
2197 if let Some(text) = text {
2198 write_config(&hub, text);
2199 }
2200
2201 assert_eq!(hub.suggest_model().unwrap(), None);
2202 }
2203 }
2204
2205 #[test]
2206 fn suggest_model_returns_configured_string_including_empty() {
2207 for value in ["smart", ""] {
2208 let (_dir, hub) = temp_hub();
2209 write_config(&hub, &format!("[suggest]\nmodel = {value:?}\n"));
2210
2211 assert_eq!(hub.suggest_model().unwrap().as_deref(), Some(value));
2212 }
2213 }
2214
2215 #[test]
2216 fn suggest_model_rejects_non_string_value() {
2217 let (_dir, hub) = temp_hub();
2218 write_config(&hub, "[suggest]\nmodel = 42\n");
2219
2220 assert!(matches!(
2221 hub.suggest_model(),
2222 Err(ConfigError::Invalid(message)) if message.contains("suggest.model")
2223 ));
2224 }
2225
2226 #[test]
2227 fn compact_review_mode_defaults_to_none_when_config_or_value_is_missing() {
2228 for text in [
2229 None,
2230 Some("[theme]\nmode = \"dark\"\n"),
2231 Some("[compaction]\n"),
2232 ] {
2233 let (_dir, hub) = temp_hub();
2234 if let Some(text) = text {
2235 write_config(&hub, text);
2236 }
2237
2238 assert_eq!(hub.compact_review_mode().unwrap(), None);
2239 }
2240 }
2241
2242 #[test]
2243 fn compact_review_mode_parses_supported_values() {
2244 for (value, expected) in [
2245 ("always", crate::CompactReviewMode::Always),
2246 ("manual-only", crate::CompactReviewMode::ManualOnly),
2247 ("manual_only", crate::CompactReviewMode::ManualOnly),
2248 ("never", crate::CompactReviewMode::Never),
2249 ] {
2250 let (_dir, hub) = temp_hub();
2251 write_config(&hub, &format!("[compaction]\nreview = {value:?}\n"));
2252
2253 assert_eq!(hub.compact_review_mode().unwrap(), Some(expected));
2254 }
2255 }
2256
2257 #[test]
2258 fn compact_review_mode_rejects_unknown_or_non_string_value() {
2259 for value in ["\"sometimes\"", "true"] {
2260 let (_dir, hub) = temp_hub();
2261 write_config(&hub, &format!("[compaction]\nreview = {value}\n"));
2262
2263 assert!(matches!(
2264 hub.compact_review_mode(),
2265 Err(ConfigError::Invalid(message)) if message.contains("compaction.review")
2266 ));
2267 }
2268 }
2269
2270 #[test]
2271 fn auto_snapshot_defaults_to_none_when_config_or_value_is_missing() {
2272 for text in [
2273 None,
2274 Some("[theme]\nmode = \"dark\"\n"),
2275 Some("[registry]\n"),
2276 ] {
2277 let (_dir, hub) = temp_hub();
2278 if let Some(text) = text {
2279 write_config(&hub, text);
2280 }
2281
2282 assert_eq!(hub.auto_snapshot().unwrap(), None);
2283 }
2284 }
2285
2286 #[test]
2287 fn auto_snapshot_reads_boolean_values() {
2288 for value in [true, false] {
2289 let (_dir, hub) = temp_hub();
2290 write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2291
2292 assert_eq!(hub.auto_snapshot().unwrap(), Some(value));
2293 }
2294 }
2295
2296 #[test]
2297 fn auto_snapshot_reads_integer_values() {
2298 for (value, expected) in [(1, true), (0, false)] {
2299 let (_dir, hub) = temp_hub();
2300 write_config(&hub, &format!("[registry]\nauto_snapshot = {value}\n"));
2301
2302 assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2303 }
2304 }
2305
2306 #[test]
2307 fn auto_snapshot_only_enables_exact_true_string() {
2308 for (value, expected) in [("true", true), ("yes", false)] {
2309 let (_dir, hub) = temp_hub();
2310 write_config(&hub, &format!("[registry]\nauto_snapshot = {value:?}\n"));
2311
2312 assert_eq!(hub.auto_snapshot().unwrap(), Some(expected));
2313 }
2314 }
2315
2316 #[test]
2317 fn auto_snapshot_rejects_unsupported_type() {
2318 let (_dir, hub) = temp_hub();
2319 write_config(&hub, "[registry]\nauto_snapshot = [true]\n");
2320
2321 assert!(matches!(
2322 hub.auto_snapshot(),
2323 Err(ConfigError::Invalid(message)) if message.contains("registry.auto_snapshot")
2324 ));
2325 }
2326
2327 #[test]
2328 fn fs_access_mode_defaults_to_none_when_config_is_missing() {
2329 let (_dir, hub) = temp_hub();
2330
2331 assert_eq!(hub.fs_access_mode().unwrap(), None);
2332 }
2333
2334 #[test]
2335 fn fs_access_mode_defaults_to_none_when_section_or_mode_is_missing() {
2336 for text in ["[theme]\nmode = \"dark\"\n", "[fs_access]\n"] {
2337 let (_dir, hub) = temp_hub();
2338 write_config(&hub, text);
2339
2340 assert_eq!(hub.fs_access_mode().unwrap(), None);
2341 }
2342 }
2343
2344 #[test]
2345 fn fs_access_mode_parses_canonical_and_alias_values() {
2346 for (mode, expected) in [
2347 ("read-only", crate::fs_access::FsAccessMode::ReadOnly),
2348 ("ws", crate::fs_access::FsAccessMode::WorkspaceWrite),
2349 (
2350 "danger-full-access",
2351 crate::fs_access::FsAccessMode::DangerFullAccess,
2352 ),
2353 ] {
2354 let (_dir, hub) = temp_hub();
2355 write_config(&hub, &format!("[fs_access]\nmode = {mode:?}\n"));
2356
2357 assert_eq!(hub.fs_access_mode().unwrap(), Some(expected));
2358 }
2359 }
2360
2361 #[test]
2362 fn fs_access_mode_rejects_unknown_mode() {
2363 let (_dir, hub) = temp_hub();
2364 write_config(&hub, "[fs_access]\nmode = \"chaos\"\n");
2365
2366 assert!(matches!(
2367 hub.fs_access_mode(),
2368 Err(ConfigError::Invalid(message)) if message.contains("unknown fs access mode")
2369 ));
2370 }
2371
2372 #[test]
2373 fn fs_access_mode_rejects_non_string_mode() {
2374 let (_dir, hub) = temp_hub();
2375 write_config(&hub, "[fs_access]\nmode = true\n");
2376
2377 assert!(matches!(
2378 hub.fs_access_mode(),
2379 Err(ConfigError::Invalid(message)) if message.contains("fs_access.mode")
2380 ));
2381 }
2382
2383 fn model<'a>(
2384 old_name: Option<&'a str>,
2385 name: &'a str,
2386 model: &'a str,
2387 ) -> ModelConfigUpdate<'a> {
2388 ModelConfigUpdate {
2389 old_name,
2390 name,
2391 model,
2392 provider: Some("test"),
2393 context_budget: 100_000,
2394 thinking: false,
2395 max_tokens: None,
2396 enabled: true,
2397 }
2398 }
2399
2400 #[test]
2401 fn model_migration_preserves_existing_provider_name() {
2402 let (_dir, hub) = temp_hub();
2403 write_config(
2404 &hub,
2405 r#"[providers.openai]
2406kind = "openai"
2407api_key = "existing"
2408
2409[models.legacy]
2410model = "gpt"
2411provider = "openai"
2412api_key = "legacy"
2413"#,
2414 );
2415
2416 let outcome = hub.migrate_model_config_if_needed().unwrap();
2417 assert!(matches!(
2418 outcome,
2419 crate::model_registry::ModelMigrationOutcome::Migrated { .. }
2420 ));
2421 let text = hub.read_config_toml().unwrap();
2422 assert!(text.contains("[providers.openai]"));
2423 assert!(text.contains("api_key = \"existing\""));
2424 assert!(text.contains("[providers.openai-2]"));
2425 assert!(text.contains("provider = \"openai-2\""));
2426 }
2427
2428 #[test]
2429 fn model_migration_preserves_unversioned_provider_reference() {
2430 let (dir, hub) = temp_hub();
2431 let text = r#"[providers.openai]
2432kind = "openai"
2433api_key = "existing"
2434
2435[models.current]
2436model = "gpt"
2437provider = "openai"
2438"#;
2439 write_config(&hub, text);
2440
2441 assert_eq!(
2442 hub.migrate_model_config_if_needed().unwrap(),
2443 crate::model_registry::ModelMigrationOutcome::NotNeeded
2444 );
2445 assert_eq!(hub.read_config_toml().unwrap(), text);
2446 assert!(!dir.path().join("config.toml.bak").exists());
2447 }
2448
2449 #[test]
2450 fn model_migration_rejects_invalid_and_future_versions() {
2451 for version in ["\"2\"", "3"] {
2452 let (_dir, hub) = temp_hub();
2453 let text = format!(
2454 "config_version = {version}\n[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n"
2455 );
2456 write_config(&hub, &text);
2457 assert!(hub.migrate_model_config_if_needed().is_err());
2458 assert_eq!(hub.read_config_toml().unwrap(), text);
2459 }
2460 }
2461
2462 #[test]
2463 fn model_migration_backup_conflict_preserves_source() {
2464 let (dir, hub) = temp_hub();
2465 let text = "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\n";
2466 write_config(&hub, text);
2467 std::fs::write(dir.path().join("config.toml.bak"), "older backup").unwrap();
2468
2469 assert!(matches!(
2470 hub.migrate_model_config_if_needed(),
2471 Err(ConfigError::Invalid(message)) if message.contains("backup conflict")
2472 ));
2473 assert_eq!(hub.read_config_toml().unwrap(), text);
2474 }
2475
2476 #[cfg(unix)]
2477 #[test]
2478 fn model_migration_backup_is_owner_only() {
2479 use std::os::unix::fs::PermissionsExt;
2480 let (dir, hub) = temp_hub();
2481 write_config(
2482 &hub,
2483 "[models.legacy]\nmodel = \"gpt\"\nprovider = \"openai\"\napi_key = \"secret\"\n",
2484 );
2485
2486 hub.migrate_model_config_if_needed().unwrap();
2487
2488 let mode = std::fs::metadata(dir.path().join("config.toml.bak"))
2489 .unwrap()
2490 .permissions()
2491 .mode()
2492 & 0o777;
2493 assert_eq!(mode, 0o600);
2494 }
2495
2496 #[test]
2497 fn duplicate_model_name_is_rejected_without_writing() {
2498 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2499 let (_dir, hub) = temp_hub();
2500 hub.upsert_model(model(None, "shared", "provider/a"))
2501 .unwrap();
2502 let before = hub.read_config_toml().unwrap();
2503
2504 let error = hub
2505 .upsert_model(model(None, "shared", "provider/b"))
2506 .unwrap_err();
2507
2508 assert!(matches!(
2509 error,
2510 ConfigError::NameConflict {
2511 domain: "models",
2512 ..
2513 }
2514 ));
2515 assert_eq!(hub.read_config_toml().unwrap(), before);
2516 }
2517
2518 #[test]
2519 fn model_rename_conflict_is_rejected_without_removing_source() {
2520 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2521 let (_dir, hub) = temp_hub();
2522 hub.upsert_model(model(None, "first", "provider/a"))
2523 .unwrap();
2524 hub.upsert_model(model(None, "second", "provider/b"))
2525 .unwrap();
2526 let before = hub.read_config_toml().unwrap();
2527
2528 let error = hub
2529 .upsert_model(model(Some("first"), "second", "provider/a"))
2530 .unwrap_err();
2531
2532 assert!(matches!(error, ConfigError::NameConflict { .. }));
2533 assert_eq!(hub.read_config_toml().unwrap(), before);
2534 }
2535
2536 #[test]
2537 fn model_and_alias_share_a_namespace() {
2538 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2539 let (_dir, hub) = temp_hub();
2540 hub.upsert_model(model(None, "smart", "provider/a"))
2541 .unwrap();
2542 assert!(matches!(
2543 hub.add_alias("smart", "provider/a"),
2544 Err(ConfigError::NameConflict {
2545 domain: "models",
2546 ..
2547 })
2548 ));
2549
2550 hub.add_alias("cheap", "provider/a").unwrap();
2551 assert!(matches!(
2552 hub.upsert_model(model(None, "cheap", "provider/b")),
2553 Err(ConfigError::NameConflict {
2554 domain: "alias",
2555 ..
2556 })
2557 ));
2558 }
2559
2560 #[test]
2561 fn alias_rename_conflict_is_rejected_without_removing_source() {
2562 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2563 let (_dir, hub) = temp_hub();
2564 hub.add_alias("first", "provider/a").unwrap();
2565 hub.add_alias("second", "provider/b").unwrap();
2566 let before = hub.read_config_toml().unwrap();
2567
2568 let error = hub
2569 .update_alias(Some("first"), "second", "provider/a")
2570 .unwrap_err();
2571
2572 assert!(matches!(
2573 error,
2574 ConfigError::NameConflict {
2575 domain: "alias",
2576 ..
2577 }
2578 ));
2579 assert_eq!(hub.read_config_toml().unwrap(), before);
2580 }
2581
2582 #[test]
2583 fn bind_default_model_rebinds_smart_atomically_and_keeps_cheap() {
2584 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2585 let (_dir, hub) = temp_hub();
2586 hub.add_alias("smart", "provider/old").unwrap();
2587
2588 hub.bind_default_model("provider/new").unwrap();
2589
2590 let text = hub.read_config_toml().unwrap();
2591 assert!(text.contains("[alias.smart]"));
2592 assert!(text.contains("model = \"provider/new\""));
2593 assert!(text.contains("[alias.cheap]"));
2594 assert!(text.contains("model = \"smart\""));
2595 }
2596
2597 #[test]
2598 fn bind_default_model_preserves_existing_cheap_alias() {
2599 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2600 let (_dir, hub) = temp_hub();
2601 hub.add_alias("smart", "provider/old").unwrap();
2602 hub.add_alias("cheap", "provider/custom-cheap").unwrap();
2603
2604 hub.bind_default_model("provider/new").unwrap();
2605
2606 let cfg = crate::model_registry::parse_config(&hub.read_config_toml().unwrap()).unwrap();
2607 assert_eq!(cfg.aliases["smart"].model, "provider/new");
2608 assert_eq!(cfg.aliases["cheap"].model, "provider/custom-cheap");
2609 }
2610
2611 #[test]
2612 fn bind_default_model_does_not_overwrite_smart_model() {
2613 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2614 let (_dir, hub) = temp_hub();
2615 hub.upsert_model(model(None, "smart", "provider/model"))
2616 .unwrap();
2617 let before = hub.read_config_toml().unwrap();
2618
2619 assert!(matches!(
2620 hub.bind_default_model("provider/new"),
2621 Err(ConfigError::NameConflict {
2622 domain: "models",
2623 ..
2624 })
2625 ));
2626 assert_eq!(hub.read_config_toml().unwrap(), before);
2627 }
2628
2629 #[test]
2630 fn distinct_names_may_use_the_same_provider_model_id() {
2631 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2632 let (_dir, hub) = temp_hub();
2633 hub.upsert_model(model(None, "first", "provider/shared"))
2634 .unwrap();
2635 hub.upsert_model(model(None, "second", "provider/shared"))
2636 .unwrap();
2637
2638 let text = hub.read_config_toml().unwrap();
2639 assert!(text.contains("[models.first]"));
2640 assert!(text.contains("[models.second]"));
2641 }
2642
2643 #[test]
2644 fn mcp_upsert_preserves_existing_json_servers_and_overrides_toml_by_name() {
2645 let (_dir, hub) = temp_hub();
2646 std::fs::write(
2647 hub.config_toml_path(),
2648 "[[mcp]]\nname = \"shared\"\ncommand = \"from-toml\"\n",
2649 )
2650 .unwrap();
2651 hub.save_mcp(&[crate::mcp::McpServerConfig::stdio(
2652 "existing",
2653 "existing-command",
2654 vec![],
2655 crate::tool::Tier::Two,
2656 30_000,
2657 )])
2658 .unwrap();
2659
2660 hub.upsert_mcp(crate::mcp::McpServerConfig::stdio(
2661 "shared",
2662 "from-json",
2663 vec![],
2664 crate::tool::Tier::Three,
2665 30_000,
2666 ))
2667 .unwrap();
2668
2669 let configs = hub.load_local_mcp();
2670 assert_eq!(configs.len(), 2);
2671 assert_eq!(
2672 configs
2673 .iter()
2674 .find(|cfg| cfg.name == "shared")
2675 .unwrap()
2676 .command,
2677 "from-json"
2678 );
2679 assert!(configs.iter().any(|cfg| cfg.name == "existing"));
2680 assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2681 }
2682
2683 #[test]
2684 fn mcp_toggle_toml_server_persists_json_override() {
2685 let (_dir, hub) = temp_hub();
2686 std::fs::write(
2687 hub.config_toml_path(),
2688 "[[mcp]]\nname = \"exa\"\ncommand = \"exa-mcp-server\"\n",
2689 )
2690 .unwrap();
2691
2692 assert!(hub.toggle_mcp("exa").unwrap());
2693
2694 let configs = hub.load_local_mcp();
2695 assert!(
2696 configs
2697 .iter()
2698 .find(|cfg| cfg.name == "exa")
2699 .unwrap()
2700 .disabled
2701 );
2702 assert!(hub.mcp_json_path().exists());
2703 }
2704
2705 #[test]
2706 fn mcp_remove_updates_json_atomically() {
2707 let (_dir, hub) = temp_hub();
2708 hub.save_mcp(&[
2709 crate::mcp::McpServerConfig::stdio(
2710 "first",
2711 "echo",
2712 vec![],
2713 crate::tool::Tier::Two,
2714 30_000,
2715 ),
2716 crate::mcp::McpServerConfig::stdio(
2717 "second",
2718 "ls",
2719 vec![],
2720 crate::tool::Tier::Two,
2721 30_000,
2722 ),
2723 ])
2724 .unwrap();
2725
2726 hub.remove_mcp("first").unwrap();
2727
2728 let configs = hub.load_local_mcp();
2729 assert_eq!(configs.len(), 1);
2730 assert_eq!(configs[0].name, "second");
2731 assert!(!hub.config_dir().join(".mcp_servers.json.tmp").exists());
2732 }
2733
2734 #[test]
2735 fn alias_updates_preserve_comments_and_other_sections() {
2736 let _registry_lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2737 let (_dir, hub) = temp_hub();
2738 std::fs::write(
2739 hub.config_toml_path(),
2740 "# keep me\n[theme]\nname = \"dark\"\n\n[alias.old]\nmodel = \"provider/a\"\n",
2741 )
2742 .unwrap();
2743
2744 hub.update_alias(Some("old"), "new", "provider/b").unwrap();
2745
2746 let text = hub.read_config_toml().unwrap();
2747 assert!(text.contains("# keep me"));
2748 assert!(text.contains("[theme]"));
2749 assert!(text.contains("[alias.new]"));
2750 assert!(!text.contains("[alias.old]"));
2751 }
2752}