Skip to main content

atman_runtime/
config_hub.rs

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