1use std::collections::{BTreeMap, HashMap};
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use forge_primitives::GotifyConfig;
27use forge_primitives::account::Provider;
28use forge_primitives::browser::ProfileKey;
29use forge_primitives::permission::PermissionMode;
30use forge_primitives::slack::SlackConfig;
31use forge_primitives::{ClientConfig, ServerConfig};
32use serde::Deserialize;
33
34use crate::error::WorkspaceError;
35
36#[derive(Clone, Default, Deserialize, PartialEq)]
39#[serde(deny_unknown_fields)]
40pub struct HuggingfaceSettings {
41 #[serde(default)]
42 pub token: Option<String>,
43}
44
45impl std::fmt::Debug for HuggingfaceSettings {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("HuggingfaceSettings")
49 .field("token", &self.token.as_ref().map(|_| "[redacted]"))
50 .finish()
51 }
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub(crate) struct VaultSection {
61 #[serde(default)]
62 pub url: Option<String>,
63 #[serde(default)]
64 pub email: Option<String>,
65 #[serde(default)]
68 pub idle_minutes: Option<u64>,
69}
70
71#[derive(Clone, PartialEq, Eq)]
73pub(crate) struct VaultConfig {
74 pub url: String,
75 pub email: String,
76 pub idle_minutes: Option<u64>,
78}
79
80impl std::fmt::Debug for VaultConfig {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("VaultConfig")
85 .field("url", &self.url)
86 .field("email", &"[redacted]")
87 .field("idle_minutes", &self.idle_minutes)
88 .finish()
89 }
90}
91
92impl VaultSection {
93 pub(crate) fn into_config(self) -> Result<Option<VaultConfig>, String> {
96 let url = self.url.unwrap_or_default().trim().to_owned();
97 let email = self.email.unwrap_or_default().trim().to_owned();
98 if url.is_empty() {
99 return Err("`url` is required when `[vault]` is present".to_owned());
100 }
101 if !url.starts_with("https://") {
102 return Err("`url` must be an https:// URL; http is refused".to_owned());
103 }
104 if email.is_empty() {
105 return Err("`email` is required when `[vault]` is present".to_owned());
106 }
107 if self.idle_minutes == Some(0) {
110 return Err("`idle_minutes` must be at least 1; omit it for no idle lock".to_owned());
111 }
112 Ok(Some(VaultConfig { url, email, idle_minutes: self.idle_minutes }))
113 }
114}
115
116#[derive(Deserialize)]
119#[serde(deny_unknown_fields)]
120struct ForgeToml {
121 #[serde(default)]
122 orgs: Vec<OrgEntry>,
123 #[serde(default)]
124 accounts: Vec<AccountEntry>,
125 #[serde(default)]
129 models: HashMap<String, ModelSettings>,
130 #[serde(default)]
134 handoff: HandoffSettings,
135 #[serde(default)]
138 dictate: crate::dictate::DictateSettings,
139 #[serde(default)]
142 tts: crate::tts::TtsSettings,
143 #[serde(default)]
146 huggingface: HuggingfaceSettings,
147 #[serde(default)]
151 gotify: Option<GotifyConfig>,
152 #[serde(default)]
156 systemone: Option<forge_system_one::SystemOneSection>,
157 #[serde(default)]
160 vault: Option<VaultSection>,
161 #[serde(default)]
164 slack: Vec<SlackConfig>,
165 #[serde(default)]
168 plugins: PluginSettings,
169 #[serde(default)]
171 gateway: Option<GatewaySettings>,
172 #[serde(default)]
175 server: ServerConfig,
176 #[serde(default)]
179 client: ClientConfig,
180 #[serde(default)]
183 browser: BrowserSettings,
184 #[serde(default)]
188 terminal: TerminalSettings,
189 #[serde(default)]
192 documents: DocumentsSettings,
193 #[serde(default)]
196 usage: UsageSettings,
197 #[serde(default)]
200 sessions: SessionsSection,
201 #[serde(default)]
206 env: HashMap<String, String>,
207}
208
209impl std::fmt::Debug for ForgeToml {
212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213 let env: BTreeMap<&str, &str> =
214 self.env.keys().map(|key| (key.as_str(), "[redacted]")).collect();
215 f.debug_struct("ForgeToml")
216 .field("orgs", &self.orgs)
217 .field("accounts", &self.accounts)
218 .field("models", &self.models)
219 .field("handoff", &self.handoff)
220 .field("dictate", &self.dictate)
221 .field("tts", &self.tts)
222 .field("huggingface", &self.huggingface)
223 .field("gotify", &self.gotify)
224 .field("systemone", &self.systemone)
225 .field("vault", &self.vault)
226 .field("slack", &self.slack)
227 .field("plugins", &self.plugins)
228 .field("gateway", &self.gateway)
229 .field("server", &self.server)
230 .field("client", &self.client)
231 .field("browser", &self.browser)
232 .field("terminal", &self.terminal)
233 .field("documents", &self.documents)
234 .field("usage", &self.usage)
235 .field("sessions", &self.sessions)
236 .field("env", &env)
237 .finish()
238 }
239}
240
241pub const DEFAULT_DOCUMENT_MAX_MIB: u64 = 10;
244
245pub const DOCUMENT_MAX_MIB_LIMIT: u64 = 190;
251
252pub const DEFAULT_USAGE_POLL_SECONDS: u64 = 60;
257
258pub const DEFAULT_HEAL: bool = true;
260
261pub const DEFAULT_HEAL_ATTEMPTS: usize = 3;
264
265pub const DEFAULT_HEAL_BACKOFF_SECONDS: [u64; 3] = [5, 30, 120];
268
269pub const DEFAULT_HEAL_STABLE_SECONDS: u64 = 600;
272
273#[derive(Debug, Default, Deserialize)]
276#[serde(deny_unknown_fields)]
277struct UsageSettings {
278 #[serde(default)]
282 poll_seconds: Option<u64>,
283}
284
285impl UsageSettings {
286 fn resolved_poll_interval(&self, path: &Path) -> Result<Duration, WorkspaceError> {
289 match self.poll_seconds {
290 None => Ok(Duration::from_secs(DEFAULT_USAGE_POLL_SECONDS)),
291 Some(0) => Err(WorkspaceError::UsagePollIntervalInvalid { path: path.to_path_buf() }),
292 Some(seconds) => Ok(Duration::from_secs(seconds)),
293 }
294 }
295}
296
297#[derive(Clone, Debug, PartialEq, Eq)]
301pub struct SessionsConfig {
302 pub heal: bool,
303 pub heal_attempts: usize,
304 pub heal_backoff: Vec<Duration>,
305 pub heal_stable: Duration,
306}
307
308impl Default for SessionsConfig {
309 fn default() -> Self {
310 Self {
311 heal: DEFAULT_HEAL,
312 heal_attempts: DEFAULT_HEAL_ATTEMPTS,
313 heal_backoff: DEFAULT_HEAL_BACKOFF_SECONDS
314 .iter()
315 .copied()
316 .map(Duration::from_secs)
317 .collect(),
318 heal_stable: Duration::from_secs(DEFAULT_HEAL_STABLE_SECONDS),
319 }
320 }
321}
322
323#[derive(Debug, Default, Deserialize)]
326#[serde(deny_unknown_fields)]
327struct SessionsSection {
328 #[serde(default)]
329 heal: Option<bool>,
330 #[serde(default)]
331 heal_attempts: Option<usize>,
332 #[serde(default)]
333 heal_backoff: Option<Vec<String>>,
334 #[serde(default)]
335 heal_stable: Option<String>,
336}
337
338impl SessionsSection {
339 fn into_config(self, path: &Path) -> Result<SessionsConfig, WorkspaceError> {
342 let heal_attempts = match self.heal_attempts {
343 None => DEFAULT_HEAL_ATTEMPTS,
344 Some(0) => {
345 return Err(WorkspaceError::SessionsHealAttemptsInvalid {
346 path: path.to_path_buf(),
347 });
348 }
349 Some(attempts) => attempts,
350 };
351 let mut heal_backoff = match &self.heal_backoff {
352 None => DEFAULT_HEAL_BACKOFF_SECONDS.iter().copied().map(Duration::from_secs).collect(),
353 Some(entries) if entries.is_empty() => {
354 return Err(WorkspaceError::SessionsBackoffEmpty { path: path.to_path_buf() });
355 }
356 Some(entries) => entries
357 .iter()
358 .map(|value| {
359 parse_session_duration(value).map_err(|()| {
360 WorkspaceError::SessionsDurationInvalid {
361 path: path.to_path_buf(),
362 key: "heal_backoff",
363 value: value.clone(),
364 }
365 })
366 })
367 .collect::<Result<Vec<_>, _>>()?,
368 };
369 if let Some(last) = heal_backoff.last().copied() {
370 while heal_backoff.len() < heal_attempts {
371 heal_backoff.push(last);
372 }
373 }
374 let heal_stable = match &self.heal_stable {
375 None => Duration::from_secs(DEFAULT_HEAL_STABLE_SECONDS),
376 Some(value) => parse_session_duration(value).map_err(|()| {
377 WorkspaceError::SessionsDurationInvalid {
378 path: path.to_path_buf(),
379 key: "heal_stable",
380 value: value.clone(),
381 }
382 })?,
383 };
384 Ok(SessionsConfig {
385 heal: self.heal.unwrap_or(DEFAULT_HEAL),
386 heal_attempts,
387 heal_backoff,
388 heal_stable,
389 })
390 }
391}
392
393fn parse_session_duration(value: &str) -> Result<Duration, ()> {
396 let value = value.trim();
397 for (suffix, millis_per_unit) in [("ms", 1_u64), ("s", 1_000), ("m", 60_000), ("h", 3_600_000)]
398 {
399 if let Some(digits) = value.strip_suffix(suffix) {
400 let amount: u64 = digits.parse().map_err(|_| ())?;
401 let millis = amount.checked_mul(millis_per_unit).ok_or(())?;
402 return Ok(Duration::from_millis(millis));
403 }
404 }
405 Err(())
406}
407
408pub(crate) fn format_session_duration(duration: Duration) -> String {
411 let millis = duration.as_millis();
412 if millis > 0 && millis % 3_600_000 == 0 {
413 format!("{}h", millis / 3_600_000)
414 } else if millis > 0 && millis % 60_000 == 0 {
415 format!("{}m", millis / 60_000)
416 } else if millis % 1_000 == 0 {
417 format!("{}s", millis / 1_000)
418 } else {
419 format!("{millis}ms")
420 }
421}
422
423#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
427#[serde(default, deny_unknown_fields)]
428pub struct BrowserSettings {
429 pub fps: u16,
431 pub quality: u8,
433 pub idle_minutes: u64,
435}
436
437impl Default for BrowserSettings {
438 fn default() -> Self {
439 Self { fps: 30, quality: 75, idle_minutes: 30 }
440 }
441}
442
443#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
448#[serde(default, deny_unknown_fields)]
449pub struct TerminalSettings {
450 pub shell: String,
453 pub scrollback_lines: usize,
455 pub idle_minutes: u64,
458}
459
460impl Default for TerminalSettings {
461 fn default() -> Self {
462 Self { shell: default_terminal_shell(), scrollback_lines: 2000, idle_minutes: 0 }
463 }
464}
465
466fn default_terminal_shell() -> String {
469 std::env::var("SHELL").ok().filter(|shell| !shell.trim().is_empty()).unwrap_or_else(|| {
470 if cfg!(target_os = "macos") { "/bin/zsh" } else { "/bin/sh" }.to_owned()
471 })
472}
473
474#[derive(Debug, Default, Deserialize)]
477#[serde(deny_unknown_fields)]
478struct DocumentsSettings {
479 #[serde(default)]
483 max_size_mib: Option<u64>,
484}
485
486impl DocumentsSettings {
487 fn resolved_max_bytes(&self, path: &Path) -> Result<u64, WorkspaceError> {
492 match self.max_size_mib {
493 None => Ok(DEFAULT_DOCUMENT_MAX_MIB * 1024 * 1024),
494 Some(mib) if mib > DOCUMENT_MAX_MIB_LIMIT => {
495 Err(WorkspaceError::DocumentCeilingTooLarge {
496 path: path.to_path_buf(),
497 value: mib,
498 })
499 }
500 Some(mib) => mib.checked_mul(1024 * 1024).filter(|bytes| *bytes > 0).ok_or_else(|| {
501 WorkspaceError::DocumentCeilingInvalid { path: path.to_path_buf(), value: mib }
502 }),
503 }
504 }
505}
506
507#[derive(Debug, Deserialize)]
510#[serde(deny_unknown_fields)]
511struct GatewaySettings {
512 #[serde(default)]
514 port: Option<u16>,
515 #[serde(default)]
517 streak_count: Option<u32>,
518 #[serde(default)]
520 streak_window_secs: Option<u64>,
521 #[serde(default)]
523 no_reset_cooldown_secs: Option<u64>,
524}
525
526impl GatewaySettings {
527 fn resolved_port(&self, path: &Path) -> Result<u16, WorkspaceError> {
531 match self.port {
532 Some(0) => Err(WorkspaceError::GatewayPortInvalid { path: path.to_path_buf() }),
533 Some(port) => Ok(port),
534 None => Ok(DEFAULT_GATEWAY_PORT),
535 }
536 }
537
538 fn resolved_rotation(
544 &self,
545 path: &Path,
546 ) -> Result<forge_gateway::rotation::RotationNumbers, WorkspaceError> {
547 fn refused_u32(
548 path: &Path,
549 key: &'static str,
550 value: Option<u32>,
551 default: u32,
552 ) -> Result<u32, WorkspaceError> {
553 match value {
554 Some(0) => {
555 Err(WorkspaceError::GatewayRotationInvalid { path: path.to_path_buf(), key })
556 }
557 Some(v) => Ok(v),
558 None => Ok(default),
559 }
560 }
561 fn refused_duration(
562 path: &Path,
563 key: &'static str,
564 value: Option<u64>,
565 default: Duration,
566 ) -> Result<Duration, WorkspaceError> {
567 match value {
568 Some(0) => {
569 Err(WorkspaceError::GatewayRotationInvalid { path: path.to_path_buf(), key })
570 }
571 Some(v) => u32::try_from(v).map(u64::from).map(Duration::from_secs).map_err(|_| {
572 WorkspaceError::GatewayRotationInvalid { path: path.to_path_buf(), key }
573 }),
574 None => Ok(default),
575 }
576 }
577 let defaults = forge_gateway::rotation::RotationNumbers::default();
578 Ok(forge_gateway::rotation::RotationNumbers {
579 streak_count: refused_u32(
580 path,
581 "streak_count",
582 self.streak_count,
583 defaults.streak_count,
584 )?,
585 streak_window: refused_duration(
586 path,
587 "streak_window_secs",
588 self.streak_window_secs,
589 defaults.streak_window,
590 )?,
591 no_reset_cooldown: refused_duration(
592 path,
593 "no_reset_cooldown_secs",
594 self.no_reset_cooldown_secs,
595 defaults.no_reset_cooldown,
596 )?,
597 })
598 }
599}
600
601impl ProjectEntry {
602 fn permission_mode(
607 &self,
608 path: &std::path::Path,
609 name: &str,
610 ) -> Result<PermissionMode, WorkspaceError> {
611 match self.permission_mode.as_deref() {
612 None => Ok(PermissionMode::Auto),
613 Some(raw) => PermissionMode::from_wire(raw).ok_or_else(|| {
614 WorkspaceError::ProjectInvalidPermissionMode {
615 path: path.to_path_buf(),
616 name: name.to_owned(),
617 value: raw.to_owned(),
618 }
619 }),
620 }
621 }
622}
623
624#[derive(Debug, Deserialize)]
628#[serde(deny_unknown_fields)]
629struct OrgEntry {
630 name: String,
631 accounts: Vec<String>,
634 #[serde(default)]
638 fallback_accounts: Vec<String>,
639 #[serde(default)]
641 browser_profile: Option<String>,
642 #[serde(default)]
645 slack: Option<SlackBinding>,
646 #[serde(default)]
647 projects: Vec<ProjectEntry>,
648}
649
650#[derive(Debug, Deserialize)]
653#[serde(deny_unknown_fields)]
654struct SlackBinding {
655 workspace: String,
656}
657
658#[derive(Deserialize)]
659#[serde(deny_unknown_fields)]
660struct ProjectEntry {
661 name: String,
662 path: String,
663 #[serde(default)]
668 model: Option<String>,
669 #[serde(default)]
672 permission_mode: Option<String>,
673 #[serde(default)]
676 max_workers: Option<usize>,
677 #[serde(default)]
680 env: HashMap<String, String>,
681 #[serde(default)]
686 env_file: Option<String>,
687 #[serde(default)]
693 document_roots: Vec<String>,
694 #[serde(default)]
697 handoff: Option<HandoffSettings>,
698 #[serde(default)]
701 browser_profile: Option<String>,
702 #[serde(default)]
705 slack: Option<SlackBinding>,
706}
707
708impl std::fmt::Debug for ProjectEntry {
711 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
712 let env: BTreeMap<&str, &str> =
713 self.env.keys().map(|key| (key.as_str(), "[redacted]")).collect();
714 f.debug_struct("ProjectEntry")
715 .field("name", &self.name)
716 .field("path", &self.path)
717 .field("model", &self.model)
718 .field("permission_mode", &self.permission_mode)
719 .field("max_workers", &self.max_workers)
720 .field("env", &env)
721 .field("env_file", &self.env_file)
722 .field("document_roots", &self.document_roots)
723 .field("handoff", &self.handoff)
724 .field("browser_profile", &self.browser_profile)
725 .field("slack", &self.slack)
726 .finish()
727 }
728}
729
730#[derive(Deserialize)]
734#[serde(deny_unknown_fields)]
735struct AccountEntry {
736 display_name: String,
737 provider: Option<forge_primitives::account::Provider>,
742 #[serde(default)]
746 base_url: Option<String>,
747 #[serde(default)]
750 token: Option<String>,
751 #[serde(default)]
754 models: Vec<String>,
755 #[serde(default)]
758 model_slugs: HashMap<String, String>,
759 #[serde(default)]
762 model_aliases: HashMap<String, Vec<String>>,
763 #[serde(default)]
767 env: HashMap<String, String>,
768}
769
770impl std::fmt::Debug for AccountEntry {
772 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
773 let env: BTreeMap<&str, &str> =
774 self.env.keys().map(|key| (key.as_str(), "[redacted]")).collect();
775 f.debug_struct("AccountEntry")
776 .field("display_name", &self.display_name)
777 .field("provider", &self.provider)
778 .field("base_url", &self.base_url)
779 .field("token", &self.token.as_ref().map(|_| "[redacted]"))
780 .field("models", &self.models)
781 .field("model_slugs", &self.model_slugs)
782 .field("model_aliases", &self.model_aliases)
783 .field("env", &env)
784 .finish()
785 }
786}
787
788pub(crate) use forge_primitives::account::LoadedAccount;
792
793#[derive(Debug, Clone, Deserialize, Default)]
796#[serde(deny_unknown_fields)]
797pub struct PluginSettings {
798 #[serde(default)]
804 pub auto_update: bool,
805}
806
807pub(crate) const DEFAULT_MAX_WORKERS_PER_PROJECT: usize = 2;
810
811#[derive(Debug, Clone, Deserialize, Default)]
817#[serde(deny_unknown_fields)]
818pub struct ModelSettings {
819 #[serde(default)]
826 pub auto_compact_window: Option<i64>,
827}
828
829pub const DEFAULT_HANDOFF_AT_PERCENT: u8 = 85;
831
832pub const DEFAULT_HANDOFF_AFTER_COMPACTIONS: u32 = 1;
836
837#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
840#[serde(deny_unknown_fields)]
841pub struct HandoffSettings {
842 #[serde(default)]
844 pub enabled: Option<bool>,
845 #[serde(default)]
849 pub at_percent: Option<u32>,
850 #[serde(default)]
852 pub after_compactions: Option<u32>,
853}
854
855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
858pub struct HandoffPolicy {
859 pub enabled: bool,
860 pub at_percent: u8,
861 pub after_compactions: u32,
862}
863
864impl Default for HandoffPolicy {
865 fn default() -> Self {
866 Self {
867 enabled: true,
868 at_percent: DEFAULT_HANDOFF_AT_PERCENT,
869 after_compactions: DEFAULT_HANDOFF_AFTER_COMPACTIONS,
870 }
871 }
872}
873
874impl HandoffPolicy {
875 pub fn resolve(global: &HandoffSettings, project: Option<&HandoffSettings>) -> Self {
876 Self {
877 enabled: project
878 .and_then(|settings| settings.enabled)
879 .or(global.enabled)
880 .unwrap_or(true),
881 at_percent: u8::try_from(
882 project
883 .and_then(|settings| settings.at_percent)
884 .or(global.at_percent)
885 .unwrap_or(u32::from(DEFAULT_HANDOFF_AT_PERCENT)),
886 )
887 .unwrap_or(DEFAULT_HANDOFF_AT_PERCENT),
888 after_compactions: project
889 .and_then(|settings| settings.after_compactions)
890 .or(global.after_compactions)
891 .unwrap_or(DEFAULT_HANDOFF_AFTER_COMPACTIONS),
892 }
893 }
894}
895
896fn validate_handoff_settings(
899 settings: &HandoffSettings,
900 path: &Path,
901 scope: &str,
902) -> Result<(), WorkspaceError> {
903 if let Some(value) = settings.at_percent
904 && !(1..=100).contains(&value)
905 {
906 return Err(WorkspaceError::HandoffAtPercentInvalid {
907 path: path.to_path_buf(),
908 scope: scope.to_owned(),
909 value,
910 });
911 }
912 if settings.after_compactions == Some(0) {
913 return Err(WorkspaceError::HandoffAfterCompactionsInvalid {
914 path: path.to_path_buf(),
915 scope: scope.to_owned(),
916 });
917 }
918 Ok(())
919}
920
921pub(crate) struct LoadedConfig {
922 pub projects: Vec<LoadedProject>,
923 pub global_env: HashMap<String, String>,
927 pub org_browser_profiles: HashMap<String, String>,
930 pub org_slack: HashMap<String, String>,
933 pub default_index: usize,
938 pub accounts: Vec<LoadedAccount>,
939 pub models: HashMap<String, ModelSettings>,
943 pub handoff: HandoffSettings,
946 pub dictate: crate::dictate::DictateSettings,
949 pub tts: crate::tts::TtsSettings,
952 pub huggingface: HuggingfaceSettings,
954 pub gotify: Option<GotifyConfig>,
957 pub systemone: Option<forge_system_one::SystemOneConfig>,
960 pub vault: Option<VaultConfig>,
963 pub slack: Vec<SlackConfig>,
966 pub plugins: PluginSettings,
969 pub gateway_port: u16,
972 pub gateway_rotation: forge_gateway::rotation::RotationNumbers,
974 pub server: ServerConfig,
977 pub client: ClientConfig,
980 pub browser: BrowserSettings,
983 pub terminal: TerminalSettings,
987 pub document_max_bytes: u64,
991 pub usage_poll_interval: Duration,
995 pub sessions: SessionsConfig,
998 pub raw_fingerprint: String,
1001}
1002
1003impl std::fmt::Debug for LoadedConfig {
1008 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1009 f.debug_struct("LoadedConfig")
1010 .field("projects", &self.projects)
1011 .field("global_env", &self.global_env.keys().map(String::as_str).collect::<Vec<_>>())
1012 .field("org_browser_profiles", &self.org_browser_profiles)
1013 .field("org_slack", &self.org_slack)
1014 .field("default_index", &self.default_index)
1015 .field("accounts", &self.accounts)
1016 .field("models", &self.models)
1017 .field("handoff", &self.handoff)
1018 .field("dictate", &self.dictate)
1019 .field("tts", &self.tts)
1020 .field("huggingface", &self.huggingface)
1021 .field("gotify", &self.gotify)
1022 .field("systemone", &self.systemone)
1023 .field("vault", &self.vault)
1024 .field("slack", &self.slack)
1025 .field("plugins", &self.plugins)
1026 .field("gateway_port", &self.gateway_port)
1027 .field("gateway_rotation", &self.gateway_rotation)
1028 .field("server", &self.server)
1029 .field("client", &self.client)
1030 .field("browser", &self.browser)
1031 .field("terminal", &self.terminal)
1032 .field("document_max_bytes", &self.document_max_bytes)
1033 .field("usage_poll_interval", &self.usage_poll_interval)
1034 .field("sessions", &self.sessions)
1035 .field("raw_fingerprint", &self.raw_fingerprint)
1036 .finish()
1037 }
1038}
1039
1040#[derive(Debug, Clone, Default, PartialEq, Eq)]
1044pub struct ConfigReload {
1045 pub changed: Vec<String>,
1047 pub restart_only: Vec<String>,
1049}
1050
1051impl ConfigReload {
1052 pub fn moved(&self, prefix: &str) -> bool {
1055 self.changed.iter().chain(&self.restart_only).any(|path| path.starts_with(prefix))
1056 }
1057}
1058
1059impl LoadedConfig {
1060 pub(crate) fn diff(&self, next: &Self) -> ConfigReload {
1063 crate::settings::diff(self, next)
1064 }
1065}
1066
1067fn slack_binding_label(
1071 binding: &SlackBinding,
1072 scope: &str,
1073 labels: &std::collections::BTreeSet<String>,
1074 path: &std::path::Path,
1075) -> Result<String, WorkspaceError> {
1076 let label = binding.workspace.trim();
1077 if labels.contains(label) {
1078 return Ok(label.to_owned());
1079 }
1080 let configured = if labels.is_empty() {
1081 "none".to_owned()
1082 } else {
1083 labels.iter().cloned().collect::<Vec<_>>().join(", ")
1084 };
1085 Err(WorkspaceError::ConfigInvalid {
1086 path: path.to_path_buf(),
1087 message: format!(
1088 "{scope} slack: no [[slack]] workspace named '{label}'; configured: {configured}"
1089 ),
1090 })
1091}
1092
1093pub const DEFAULT_GATEWAY_PORT: u16 = 8787;
1097
1098#[derive(Clone)]
1099pub(crate) struct LoadedProject {
1100 pub name: String,
1101 pub path: PathBuf,
1102 pub display_path: String,
1106 pub org: String,
1110 pub accounts: Vec<String>,
1114 pub fallback_accounts: Vec<String>,
1117 pub model: Option<String>,
1122 pub env: HashMap<String, String>,
1128 pub env_file: Option<String>,
1131 pub max_workers: Option<usize>,
1134 pub permission_mode: PermissionMode,
1137 pub document_roots: Vec<PathBuf>,
1141 pub handoff: Option<HandoffSettings>,
1144 pub browser_profile: ProfileKey,
1147 pub slack_workspace: Option<String>,
1151}
1152
1153impl std::fmt::Debug for LoadedProject {
1157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1158 let env: BTreeMap<&str, &str> =
1159 self.env.keys().map(|key| (key.as_str(), "[redacted]")).collect();
1160 f.debug_struct("LoadedProject")
1161 .field("name", &self.name)
1162 .field("path", &self.path)
1163 .field("display_path", &self.display_path)
1164 .field("org", &self.org)
1165 .field("accounts", &self.accounts)
1166 .field("fallback_accounts", &self.fallback_accounts)
1167 .field("model", &self.model)
1168 .field("env", &env)
1169 .field("env_file", &self.env_file)
1170 .field("max_workers", &self.max_workers)
1171 .field("permission_mode", &self.permission_mode)
1172 .field("document_roots", &self.document_roots)
1173 .field("handoff", &self.handoff)
1174 .field("browser_profile", &self.browser_profile)
1175 .field("slack_workspace", &self.slack_workspace)
1176 .finish()
1177 }
1178}
1179
1180pub(crate) fn session_env(
1186 project: &LoadedProject,
1187 account_env: &HashMap<String, String>,
1188) -> HashMap<String, String> {
1189 let mut env = account_env.clone();
1190 env.extend(project.env.iter().map(|(k, v)| (k.clone(), v.clone())));
1191 env
1192}
1193
1194pub(crate) fn seat_env(
1202 project: &LoadedProject,
1203 account_env: &HashMap<String, String>,
1204) -> HashMap<String, String> {
1205 let mut env: HashMap<String, String> = std::env::vars().collect();
1206 env.extend(session_env(project, account_env));
1207 env
1208}
1209
1210pub(crate) fn applied_env_keys(project: &LoadedProject) -> String {
1213 let mut keys: Vec<&str> = project.env.keys().map(String::as_str).collect();
1214 keys.sort_unstable();
1215 keys.join(", ")
1216}
1217
1218impl LoadedConfig {
1219 pub(crate) fn default_project(&self) -> &LoadedProject {
1220 &self.projects[self.default_index]
1221 }
1222
1223 #[cfg(any(test, feature = "testing"))]
1229 pub(crate) fn empty_for_test() -> Self {
1230 Self {
1231 projects: Vec::new(),
1232 global_env: HashMap::new(),
1233 org_browser_profiles: HashMap::new(),
1234 org_slack: HashMap::new(),
1235 default_index: 0,
1236 accounts: Vec::new(),
1237 models: HashMap::new(),
1238 handoff: HandoffSettings::default(),
1239 dictate: crate::dictate::DictateSettings::default(),
1240 tts: crate::tts::TtsSettings::default(),
1241 huggingface: HuggingfaceSettings::default(),
1242 gotify: None,
1243 vault: None,
1244 systemone: None,
1245 slack: Vec::new(),
1246 plugins: PluginSettings::default(),
1247 gateway_port: DEFAULT_GATEWAY_PORT,
1248 gateway_rotation: forge_gateway::rotation::RotationNumbers::default(),
1249 server: ServerConfig::default(),
1250 client: ClientConfig::default(),
1251 browser: BrowserSettings::default(),
1252 terminal: TerminalSettings::default(),
1253 document_max_bytes: DEFAULT_DOCUMENT_MAX_MIB * 1024 * 1024,
1254 usage_poll_interval: Duration::from_secs(DEFAULT_USAGE_POLL_SECONDS),
1255 sessions: SessionsConfig::default(),
1256 raw_fingerprint: String::new(),
1257 }
1258 }
1259}
1260
1261pub(crate) fn forge_data_dir(config_dir: &Path) -> PathBuf {
1266 config_dir.join("forge")
1267}
1268
1269pub(crate) fn ensure_forge_data_dir(config_dir: &Path) -> std::io::Result<PathBuf> {
1271 let dir = forge_data_dir(config_dir);
1272 fs::create_dir_all(&dir)?;
1273 Ok(dir)
1274}
1275
1276fn read_config(config_dir: &Path) -> Result<(PathBuf, String), WorkspaceError> {
1279 let path = forge_data_dir(config_dir).join("forge.toml");
1280 match fs::read_to_string(&path) {
1281 Ok(raw) => Ok((path, raw)),
1282 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1283 Err(WorkspaceError::ConfigMissing { path })
1284 }
1285 Err(e) => Err(WorkspaceError::ConfigInvalid { path, message: format!("io error: {e}") }),
1286 }
1287}
1288
1289fn resolve_server(
1297 server: ServerConfig,
1298 gateway_port: u16,
1299 path: &Path,
1300) -> Result<ServerConfig, WorkspaceError> {
1301 if !server.enabled {
1302 return Ok(server);
1303 }
1304 if server.port == 0 {
1305 return Err(WorkspaceError::ServerPortInvalid { path: path.to_path_buf() });
1306 }
1307 if server.port == gateway_port {
1308 return Err(WorkspaceError::ServerPortTakenByGateway {
1309 path: path.to_path_buf(),
1310 port: gateway_port,
1311 });
1312 }
1313 Ok(server)
1314}
1315
1316fn resolve_client(client: ClientConfig, path: &Path) -> Result<ClientConfig, WorkspaceError> {
1320 for (key, value, shipped) in [
1321 ("mark", client.mark.as_deref(), forge_primitives::client::MARK_NAMES),
1322 ("theme", client.theme.as_deref(), forge_primitives::client::THEME_NAMES),
1323 ("font", client.font.as_deref(), forge_primitives::client::FONT_NAMES),
1324 ] {
1325 if let Some(value) = value
1326 && !shipped.contains(&value)
1327 {
1328 return Err(WorkspaceError::ClientNameUnknown {
1329 path: path.to_path_buf(),
1330 key,
1331 value: value.to_owned(),
1332 });
1333 }
1334 }
1335 Ok(client)
1336}
1337
1338fn parse_position(raw: &str, span: Option<std::ops::Range<usize>>) -> String {
1349 let Some(span) = span else { return String::new() };
1350 let Some(upto) = raw.get(..span.start) else { return String::new() };
1351 let line = upto.matches('\n').count() + 1;
1352 let column = upto.rsplit('\n').next().map_or(0, |tail| tail.chars().count()) + 1;
1353 let key = raw
1354 .lines()
1355 .nth(line - 1)
1356 .and_then(|text| text.split_once('='))
1357 .map(|(head, _)| head.trim())
1358 .filter(|head| !head.is_empty() && head.chars().all(is_bare_key_char));
1359 match key {
1360 Some(key) => format!(" at line {line}, column {column} in `{key}`"),
1361 None => format!(" at line {line}, column {column}"),
1362 }
1363}
1364
1365fn is_bare_key_char(ch: char) -> bool {
1367 ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.'
1368}
1369
1370fn scrub_quoted(message: &str) -> String {
1374 let mut out = String::with_capacity(message.len());
1375 let mut rest = message;
1376 loop {
1377 let Some((before, after)) = rest.split_once('"') else {
1378 out.push_str(rest);
1379 return out;
1380 };
1381 out.push_str(before);
1382 out.push_str("[redacted]");
1383 match after.split_once('"') {
1386 Some((_, tail)) => rest = tail,
1387 None => return out,
1388 }
1389 }
1390}
1391
1392pub(crate) fn load_from_dir(config_dir: &Path) -> Result<LoadedConfig, WorkspaceError> {
1395 let (path, raw) = read_config(config_dir)?;
1396 load_from_text(path, &raw)
1397}
1398
1399pub(crate) fn load_from_text(path: PathBuf, raw: &str) -> Result<LoadedConfig, WorkspaceError> {
1403 let parsed: ForgeToml = toml::from_str(raw).map_err(|source| WorkspaceError::ConfigParse {
1404 at: parse_position(raw, source.span()),
1405 message: scrub_quoted(source.message()),
1406 path: path.clone(),
1407 })?;
1408
1409 crate::slack::validate_configs(&parsed.slack).map_err(|message| {
1413 WorkspaceError::ConfigInvalid { path: path.clone(), message: format!("slack: {message}") }
1414 })?;
1415
1416 if parsed.tts.language.is_some() {
1417 tracing::warn!(
1418 target: "forge_workspace::config",
1419 event_name = "tts_language_key_ignored",
1420 "[tts] language is no longer read; the voice id is the language choice and the 54 \
1421 voices cover eight languages, so the key and its value can be deleted",
1422 );
1423 }
1424
1425 if parsed.dictate.device.is_some() {
1426 tracing::warn!(
1427 target: "forge_workspace::config",
1428 event_name = "dictate_device_key_ignored",
1429 "[dictate] device is no longer read; capture moved to the client, which picks \
1430 its own machine's input, so the key and its value can be deleted",
1431 );
1432 }
1433
1434 if parsed.orgs.is_empty() {
1435 return Err(WorkspaceError::NoOrgsConfigured { path });
1436 }
1437 if parsed.accounts.is_empty() {
1438 return Err(WorkspaceError::NoAccountsConfigured { path });
1439 }
1440
1441 let mut global_env = parsed.env;
1445
1446 let mut seen_account_names: std::collections::HashSet<String> =
1448 std::collections::HashSet::new();
1449 let missing_provider: Vec<String> = parsed
1453 .accounts
1454 .iter()
1455 .filter(|entry| entry.provider.is_none())
1456 .map(|entry| entry.display_name.clone())
1457 .collect();
1458 if !missing_provider.is_empty() {
1459 return Err(WorkspaceError::AccountsMissingProvider { path, names: missing_provider });
1460 }
1461
1462 let mut accounts: Vec<LoadedAccount> = Vec::with_capacity(parsed.accounts.len());
1463 for mut entry in parsed.accounts {
1464 if !seen_account_names.insert(entry.display_name.clone()) {
1465 return Err(WorkspaceError::DuplicateAccount { path, name: entry.display_name });
1466 }
1467 let Some(provider) = entry.provider else {
1468 return Err(WorkspaceError::AccountsMissingProvider {
1469 path,
1470 names: vec![entry.display_name],
1471 });
1472 };
1473 let gateway_keys = [
1482 "ANTHROPIC_BASE_URL",
1483 "ANTHROPIC_AUTH_TOKEN",
1484 "CLAUDE_CODE_OAUTH_TOKEN",
1485 "ANTHROPIC_API_KEY",
1486 ];
1487 let mut dropped: Vec<&str> = Vec::new();
1488 for key in gateway_keys {
1489 for env in [&mut global_env, &mut entry.env] {
1490 if env.remove(key).is_some() {
1491 dropped.push(key);
1492 }
1493 }
1494 }
1495 if !dropped.is_empty() {
1496 dropped.dedup();
1497 tracing::warn!(
1498 target: "forge_workspace::config",
1499 event_name = "gateway_keys_dropped_from_env_layer",
1500 account = %entry.display_name,
1501 keys = %dropped.join(", "),
1502 "gateway keys in an env layer are dropped, not refused: the flat \
1503 base_url and token keys own them, and the spawn stamp owns the child",
1504 );
1505 }
1506 let mut env = global_env.clone();
1507 env.extend(entry.env);
1508 let token = match entry.token.as_deref().map(str::trim) {
1512 Some(t) if !t.is_empty() => t.to_owned(),
1513 _ => {
1514 return Err(WorkspaceError::AccountTokenRequired {
1515 path,
1516 name: entry.display_name,
1517 });
1518 }
1519 };
1520 if entry.models.is_empty() {
1521 return Err(WorkspaceError::AccountModelsRequired { path, name: entry.display_name });
1522 }
1523 for (slug_key, slug_value) in &entry.model_slugs {
1524 if !entry.models.contains(slug_key) {
1525 return Err(WorkspaceError::AccountSlugUndeclared { path, slug: slug_key.clone() });
1526 }
1527 if slug_value.trim().is_empty() {
1528 return Err(WorkspaceError::AccountSlugBlank { path, slug: slug_key.clone() });
1529 }
1530 }
1531 for (alias_key, alias_values) in &entry.model_aliases {
1532 if !entry.models.contains(alias_key) {
1533 return Err(WorkspaceError::AccountAliasUndeclared {
1534 path,
1535 alias: alias_key.clone(),
1536 });
1537 }
1538 if alias_values.is_empty() {
1539 return Err(WorkspaceError::AccountAliasEmpty { path, alias: alias_key.clone() });
1540 }
1541 }
1542 let base_url =
1543 entry.base_url.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
1544 if provider.uses_base_url() && base_url.is_none() {
1548 return Err(WorkspaceError::AccountProviderNeedsBaseUrl {
1549 path,
1550 name: entry.display_name,
1551 });
1552 }
1553 if provider == Provider::Openrouter
1560 && !base_url.as_deref().is_some_and(|v| v.trim_end_matches('/').ends_with("/api"))
1561 {
1562 return Err(WorkspaceError::OpenrouterBaseUrlNotApiRoot {
1563 path,
1564 name: entry.display_name,
1565 });
1566 }
1567 if provider == Provider::Anthropic && base_url.is_some() {
1571 tracing::warn!(
1572 target: "forge_workspace::config",
1573 account = %entry.display_name,
1574 "anthropic account declares a base_url; sessions will use that endpoint \
1575 while usage probes the official API",
1576 );
1577 }
1578 if let Some(base_url) = &base_url {
1581 env.insert("ANTHROPIC_BASE_URL".to_owned(), base_url.clone());
1582 }
1583 let credential_variable = forge_gateway::binding::credential_variable_for(provider);
1584 env.insert(credential_variable.to_owned(), token);
1585 accounts.push(LoadedAccount {
1586 display_name: entry.display_name,
1587 provider,
1588 base_url,
1589 models: entry.models,
1590 model_slugs: entry.model_slugs,
1591 model_aliases: entry.model_aliases,
1592 env,
1593 });
1594 }
1595
1596 let slack_labels: std::collections::BTreeSet<String> =
1598 parsed.slack.iter().map(|config| config.workspace.trim().to_owned()).collect();
1599 let mut seen_org_names: std::collections::HashSet<String> = std::collections::HashSet::new();
1600 let mut seen_project_names: std::collections::HashSet<String> =
1601 std::collections::HashSet::new();
1602 let mut projects: Vec<LoadedProject> = Vec::new();
1603 let mut org_browser_profiles: HashMap<String, String> = HashMap::new();
1604 let mut org_slack_bindings: HashMap<String, String> = HashMap::new();
1605 for org_entry in parsed.orgs {
1606 if !seen_org_names.insert(org_entry.name.clone()) {
1607 return Err(WorkspaceError::DuplicateOrg { path, name: org_entry.name });
1608 }
1609 if org_entry.accounts.is_empty() {
1610 return Err(WorkspaceError::EmptyOrgAccounts { path, org: org_entry.name });
1611 }
1612 for account in org_entry.accounts.iter().chain(&org_entry.fallback_accounts) {
1613 if !seen_account_names.contains(account) {
1614 let mut valid: Vec<&str> = seen_account_names.iter().map(String::as_str).collect();
1615 valid.sort_unstable();
1616 return Err(WorkspaceError::UnknownOrgAccount {
1617 path,
1618 org: org_entry.name,
1619 account: account.clone(),
1620 valid: valid.join(", "),
1621 });
1622 }
1623 }
1624 if org_entry.projects.is_empty() {
1625 return Err(WorkspaceError::EmptyOrg { path, org: org_entry.name });
1626 }
1627 let org_profile = match org_entry.browser_profile.as_deref() {
1628 Some(name) => ProfileKey::of(&org_entry.name, name).map_err(|why| {
1629 WorkspaceError::ConfigInvalid {
1630 path: path.clone(),
1631 message: format!("[[orgs]] browser_profile: {why}"),
1632 }
1633 })?,
1634 None => ProfileKey::of(&org_entry.name, "default").map_err(|why| {
1635 WorkspaceError::ConfigInvalid {
1636 path: path.clone(),
1637 message: format!("[[orgs]] name: {why}"),
1638 }
1639 })?,
1640 };
1641 org_browser_profiles.insert(org_entry.name.clone(), org_profile.name().to_owned());
1642 let org_slack = org_entry
1643 .slack
1644 .as_ref()
1645 .map(|binding| slack_binding_label(binding, "[[orgs]]", &slack_labels, &path))
1646 .transpose()?;
1647 if let Some(workspace) = &org_slack {
1648 org_slack_bindings.insert(org_entry.name.clone(), workspace.clone());
1649 }
1650 for project_entry in org_entry.projects {
1651 if !seen_project_names.insert(project_entry.name.clone()) {
1652 return Err(WorkspaceError::DuplicateProject { path, name: project_entry.name });
1653 }
1654 let permission_mode = project_entry.permission_mode(&path, &project_entry.name)?;
1655 let document_roots = project_entry
1656 .document_roots
1657 .iter()
1658 .map(|root| {
1659 let expanded = if root == "~" {
1662 dirs::home_dir()
1663 } else {
1664 Some(expand_home(root)).filter(|path| path.is_absolute())
1665 };
1666 expanded.ok_or_else(|| WorkspaceError::ProjectDocumentRootRelative {
1667 path: path.clone(),
1668 name: project_entry.name.clone(),
1669 root: root.clone(),
1670 })
1671 })
1672 .collect::<Result<Vec<_>, _>>()?;
1673 let env = resolve_project_env(
1674 &project_entry.name,
1675 project_entry.env,
1676 project_entry.env_file.clone(),
1677 );
1678 if let Some(settings) = &project_entry.handoff {
1679 validate_handoff_settings(
1680 settings,
1681 &path,
1682 &format!("project \"{}\"", project_entry.name),
1683 )?;
1684 }
1685 let slack_workspace = match &project_entry.slack {
1686 Some(binding) => {
1687 Some(slack_binding_label(binding, "[[orgs.projects]]", &slack_labels, &path)?)
1688 }
1689 None => org_slack.clone(),
1690 };
1691 let browser_profile = match project_entry.browser_profile.as_deref() {
1692 Some(name) => ProfileKey::of(&org_entry.name, name).map_err(|why| {
1693 WorkspaceError::ConfigInvalid {
1694 path: path.clone(),
1695 message: format!("[[orgs.projects]] browser_profile: {why}"),
1696 }
1697 })?,
1698 None => org_profile.clone(),
1699 };
1700 projects.push(LoadedProject {
1701 name: project_entry.name.clone(),
1702 path: expand_home(&project_entry.path),
1703 display_path: project_entry.path,
1704 org: org_entry.name.clone(),
1705 accounts: org_entry.accounts.clone(),
1706 fallback_accounts: org_entry.fallback_accounts.clone(),
1707 model: project_entry.model.clone(),
1708 env,
1709 env_file: project_entry.env_file.clone(),
1710 max_workers: project_entry.max_workers,
1711 permission_mode,
1712 document_roots,
1713 handoff: project_entry.handoff,
1714 browser_profile,
1715 slack_workspace,
1716 });
1717 if let Some(model) = &project_entry.model {
1721 let served =
1722 org_entry.accounts.iter().chain(&org_entry.fallback_accounts).any(|name| {
1723 accounts.iter().any(|a| {
1724 a.display_name == *name
1725 && forge_gateway::account::account_serves(
1726 &a.models,
1727 &a.model_aliases,
1728 model,
1729 )
1730 })
1731 });
1732 if !served {
1733 return Err(WorkspaceError::ProjectModelUndeclared {
1734 path,
1735 name: project_entry.name.clone(),
1736 model: model.clone(),
1737 });
1738 }
1739 }
1740 }
1741 }
1742
1743 if projects.is_empty() {
1744 return Err(WorkspaceError::NoProjectsConfigured { path });
1745 }
1746
1747 let default_index = {
1752 let mut alpha: Vec<usize> = (0..projects.len()).collect();
1753 alpha.sort_by(|a, b| projects[*a].name.cmp(&projects[*b].name));
1754 alpha[0]
1755 };
1756
1757 let gateway_port = match &parsed.gateway {
1758 Some(gateway) => gateway.resolved_port(&path)?,
1759 None => DEFAULT_GATEWAY_PORT,
1760 };
1761 let gateway_rotation = match &parsed.gateway {
1762 Some(gateway) => gateway.resolved_rotation(&path)?,
1763 None => forge_gateway::rotation::RotationNumbers::default(),
1764 };
1765 let server = resolve_server(parsed.server, gateway_port, &path)?;
1766 let document_max_bytes = parsed.documents.resolved_max_bytes(&path)?;
1767 let usage_poll_interval = parsed.usage.resolved_poll_interval(&path)?;
1768 let sessions = parsed.sessions.into_config(&path)?;
1769 let browser = parsed.browser;
1772 if browser.fps == 0 || browser.fps > 120 {
1773 return Err(WorkspaceError::ConfigInvalid {
1774 path,
1775 message: format!("[browser] fps must be 1 to 120, not {}", browser.fps),
1776 });
1777 }
1778 if browser.quality > 100 {
1779 return Err(WorkspaceError::ConfigInvalid {
1780 path,
1781 message: format!("[browser] quality must be 0 to 100, not {}", browser.quality),
1782 });
1783 }
1784 let terminal = parsed.terminal;
1785 if terminal.shell.trim().is_empty() {
1786 return Err(WorkspaceError::ConfigInvalid {
1787 path,
1788 message: "[terminal] shell must name a shell; remove the key to use $SHELL".to_owned(),
1789 });
1790 }
1791 let client = if server.enabled { resolve_client(parsed.client, &path)? } else { parsed.client };
1795
1796 validate_handoff_settings(&parsed.handoff, &path, "the top level")?;
1797
1798 let systemone = match parsed.systemone {
1799 Some(section) => {
1800 section.into_config().map_err(|message| WorkspaceError::ConfigInvalid {
1801 path: path.clone(),
1802 message: format!("[systemone] {message}"),
1803 })?
1804 }
1805 None => None,
1806 };
1807 let vault = match parsed.vault {
1808 Some(section) => {
1809 section.into_config().map_err(|message| WorkspaceError::ConfigInvalid {
1810 path: path.clone(),
1811 message: format!("[vault] {message}"),
1812 })?
1813 }
1814 None => None,
1815 };
1816
1817 let pinned: std::collections::HashSet<&str> =
1823 projects.iter().filter_map(|project| project.model.as_deref()).collect();
1824 for (model, parameters) in &parsed.models {
1825 if !pinned.contains(model.as_str()) {
1826 tracing::warn!(
1827 target: "forge_workspace::config",
1828 event_name = "model_parameters_unpinned",
1829 model = %model,
1830 "the [models] table names a model no project pins; nothing will read it",
1831 );
1832 }
1833 if let Some(window) = parameters.auto_compact_window
1834 && window <= 0
1835 {
1836 return Err(WorkspaceError::ModelAutoCompactWindowInvalid {
1837 path,
1838 model: model.clone(),
1839 value: window,
1840 });
1841 }
1842 }
1843
1844 if let Err(reason) = parsed.tts.configured_variant() {
1847 return Err(WorkspaceError::ConfigInvalid { path, message: reason });
1848 }
1849
1850 Ok(LoadedConfig {
1851 projects,
1852 global_env,
1853 org_browser_profiles,
1854 org_slack: org_slack_bindings,
1855 default_index,
1856 accounts,
1857 models: parsed.models,
1858 handoff: parsed.handoff,
1859 dictate: parsed.dictate,
1860 tts: parsed.tts,
1861 huggingface: parsed.huggingface,
1862 gotify: parsed.gotify,
1863 systemone,
1864 vault,
1865 slack: parsed.slack,
1866 plugins: parsed.plugins,
1867 gateway_port,
1868 gateway_rotation,
1869 server,
1870 client,
1871 browser,
1872 terminal,
1873 document_max_bytes,
1874 usage_poll_interval,
1875 sessions,
1876 raw_fingerprint: fingerprint_of(raw),
1877 })
1878}
1879
1880pub(crate) fn fingerprint_of(raw: &str) -> String {
1884 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1885 for byte in raw.as_bytes() {
1886 hash ^= u64::from(*byte);
1887 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1888 }
1889 format!("{hash:016x}")
1890}
1891
1892fn trim_setup_token<S: std::hash::BuildHasher>(env: &mut HashMap<String, String, S>) {
1900 if let Some(token) = env.get_mut(forge_gateway::CLAUDE_CODE_OAUTH_TOKEN_ENV) {
1901 *token = token.trim().to_owned();
1902 }
1903}
1904
1905fn resolve_project_env(
1906 project: &str,
1907 env: HashMap<String, String>,
1908 env_file: Option<String>,
1909) -> HashMap<String, String> {
1910 let mut merged = env_file.map(|path| read_env_file(project, &path)).unwrap_or_default();
1911 merged.extend(env);
1912 trim_setup_token(&mut merged);
1913 merged
1914}
1915
1916fn read_env_file(project: &str, raw_path: &str) -> HashMap<String, String> {
1921 let mut env = HashMap::new();
1922 let skipped = |reason: &str, detail: &str| {
1923 tracing::warn!(
1924 target: "forge_workspace::config",
1925 event_name = "project_env_file_skipped",
1926 project,
1927 path = raw_path,
1928 reason,
1929 detail,
1930 "the project's env_file entry did not fully apply",
1931 );
1932 };
1933
1934 let path = expand_home(raw_path);
1935 if !path.is_absolute() {
1936 skipped("not-absolute", "use an absolute or ~/ path");
1941 return env;
1942 }
1943 let text = match fs::read_to_string(&path) {
1944 Ok(text) => text,
1945 Err(err) => {
1946 let reason =
1947 if err.kind() == std::io::ErrorKind::NotFound { "missing" } else { "unreadable" };
1948 skipped(reason, &err.to_string());
1949 return env;
1950 }
1951 };
1952 for (number, line) in text.lines().enumerate() {
1953 let line = line.trim();
1954 if line.is_empty() || line.starts_with('#') {
1955 continue;
1956 }
1957 match line.split_once('=') {
1958 Some((key, value)) if !key.trim().is_empty() => {
1959 let value = value.trim();
1965 let value = value
1966 .strip_prefix('"')
1967 .and_then(|inner| inner.strip_suffix('"'))
1968 .or_else(|| value.strip_prefix('\'').and_then(|inner| inner.strip_suffix('\'')))
1969 .unwrap_or(value);
1970 env.insert(key.trim().to_owned(), value.to_owned());
1971 }
1972 _ => skipped("malformed-line", &format!("line {}", number + 1)),
1973 }
1974 }
1975 env
1976}
1977
1978pub(crate) fn expand_home(path: &str) -> PathBuf {
1979 if let Some(stripped) = path.strip_prefix("~/")
1980 && let Some(home) = dirs::home_dir()
1981 {
1982 return home.join(stripped);
1983 }
1984 PathBuf::from(path)
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989 use super::*;
1990 use forge_primitives::server::DEFAULT_SERVER_PORT;
1991 use std::fs;
1992 use std::net::{IpAddr, Ipv4Addr};
1993 use tempfile::tempdir;
1994
1995 fn named<'a>(config: &'a LoadedConfig, name: &str) -> &'a LoadedProject {
1997 config.projects.iter().find(|p| p.name == name).expect("declared project")
1998 }
1999
2000 fn write_config(dir: &std::path::Path, contents: &str) {
2002 let forge = ensure_forge_data_dir(dir).expect("forge/ dir");
2003 fs::write(forge.join("forge.toml"), contents).expect("write forge/forge.toml");
2004 }
2005
2006 fn minimal_config() -> &'static str {
2007 r#"
2008[[orgs]]
2009name = "Personal"
2010accounts = ["Stargate"]
2011
2012[[orgs.projects]]
2013name = "forge"
2014path = "~/Projects/forge"
2015
2016[[accounts]]
2017display_name = "Stargate"
2018token = "t"
2019models = ["claude-sonnet-5"]
2020provider = "anthropic"
2021"#
2022 }
2023
2024 #[derive(Default, Clone)]
2030 struct WarnedEvents(std::sync::Arc<std::sync::Mutex<Vec<(tracing::Level, String)>>>);
2031
2032 struct CollectEventName(String);
2033
2034 impl tracing::field::Visit for CollectEventName {
2035 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
2036 if field.name() == "event_name" {
2037 self.0 = format!("{value:?}").trim_matches('"').to_owned();
2038 }
2039 }
2040 }
2041
2042 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for WarnedEvents {
2043 fn on_event(
2044 &self,
2045 event: &tracing::Event<'_>,
2046 _ctx: tracing_subscriber::layer::Context<'_, S>,
2047 ) {
2048 let mut visitor = CollectEventName(String::new());
2049 event.record(&mut visitor);
2050 if !visitor.0.is_empty() {
2051 self.0.lock().expect("capture").push((*event.metadata().level(), visitor.0));
2052 }
2053 }
2054 }
2055
2056 impl WarnedEvents {
2057 fn warns(&self, event: &str) -> bool {
2059 self.0
2060 .lock()
2061 .expect("capture")
2062 .iter()
2063 .any(|(level, name)| name == event && *level == tracing::Level::WARN)
2064 }
2065
2066 fn names(&self) -> Vec<String> {
2068 self.0.lock().expect("capture").iter().map(|(_, name)| name.clone()).collect()
2069 }
2070 }
2071
2072 fn load_capturing(
2074 dir: &std::path::Path,
2075 ) -> (Result<LoadedConfig, WorkspaceError>, WarnedEvents) {
2076 use tracing_subscriber::layer::SubscriberExt;
2077 let logged = WarnedEvents::default();
2078 let subscriber = tracing_subscriber::registry().with(logged.clone());
2079 let loaded = tracing::subscriber::with_default(subscriber, || load_from_dir(dir));
2080 (loaded, logged)
2081 }
2082
2083 #[test]
2087 fn gateway_port_zero_is_refused_at_load() {
2088 let dir = tempdir().expect("tempdir");
2089 write_config(
2090 dir.path(),
2091 r#"
2092[[orgs]]
2093name = "Personal"
2094accounts = ["Stargate"]
2095
2096[[orgs.projects]]
2097name = "forge"
2098path = "~/Projects/forge"
2099
2100[[accounts]]
2101display_name = "Stargate"
2102token = "t"
2103models = ["claude-sonnet-5"]
2104provider = "anthropic"
2105
2106[gateway]
2107port = 0
2108"#,
2109 );
2110 let err = load_from_dir(dir.path()).expect_err("port 0 must not load");
2111 assert!(
2112 err.to_string().contains("port 0"),
2113 "the error names the unusable port, got: {err}",
2114 );
2115 }
2116
2117 #[test]
2118 fn gateway_port_defaults_when_the_section_is_absent() {
2119 let dir = tempdir().expect("tempdir");
2120 write_config(dir.path(), minimal_config());
2121 let config = load_from_dir(dir.path()).expect("absent section loads");
2122 assert_eq!(config.gateway_port, DEFAULT_GATEWAY_PORT);
2123 }
2124
2125 #[test]
2126 fn gateway_rotation_numbers_default_when_keys_are_absent() {
2127 let dir = tempdir().expect("tempdir");
2128 write_config(dir.path(), minimal_config());
2129 let config = load_from_dir(dir.path()).expect("absent keys load");
2130 assert_eq!(config.gateway_rotation, forge_gateway::rotation::RotationNumbers::default(),);
2131 }
2132
2133 #[test]
2134 fn gateway_rotation_numbers_parse_from_the_section() {
2135 let dir = tempdir().expect("tempdir");
2136 write_config(
2137 dir.path(),
2138 r#"
2139[[orgs]]
2140name = "Personal"
2141accounts = ["Stargate"]
2142[[orgs.projects]]
2143name = "forge"
2144path = "~/Projects/forge"
2145
2146[[accounts]]
2147display_name = "Stargate"
2148token = "t"
2149models = ["claude-sonnet-5"]
2150provider = "anthropic"
2151
2152[gateway]
2153streak_count = 3
2154streak_window_secs = 45
2155no_reset_cooldown_secs = 90
2156"#,
2157 );
2158 let config = load_from_dir(dir.path()).expect("section loads");
2159 assert_eq!(config.gateway_rotation.streak_count, 3);
2160 assert_eq!(config.gateway_rotation.streak_window, Duration::from_secs(45));
2161 assert_eq!(config.gateway_rotation.no_reset_cooldown, Duration::from_secs(90));
2162 }
2163
2164 #[test]
2167 fn document_ceiling_defaults_when_the_section_is_absent() {
2168 let dir = tempdir().expect("tempdir");
2169 write_config(dir.path(), minimal_config());
2170 let config = load_from_dir(dir.path()).expect("absent section loads");
2171 assert_eq!(config.document_max_bytes, 10 * 1024 * 1024, "the 10 MiB default");
2172 }
2173
2174 #[test]
2175 fn document_ceiling_parses_from_the_section() {
2176 let dir = tempdir().expect("tempdir");
2177 write_config(
2178 dir.path(),
2179 r#"
2180[[orgs]]
2181name = "Personal"
2182accounts = ["Stargate"]
2183
2184[[orgs.projects]]
2185name = "forge"
2186path = "~/Projects/forge"
2187
2188[[accounts]]
2189display_name = "Stargate"
2190token = "t"
2191models = ["claude-sonnet-5"]
2192provider = "anthropic"
2193
2194[documents]
2195max_size_mib = 3
2196"#,
2197 );
2198 let config = load_from_dir(dir.path()).expect("section loads");
2199 assert_eq!(config.document_max_bytes, 3 * 1024 * 1024);
2200 }
2201
2202 #[test]
2205 fn document_ceiling_zero_is_refused_at_load() {
2206 let dir = tempdir().expect("tempdir");
2207 write_config(
2208 dir.path(),
2209 r#"
2210[[orgs]]
2211name = "Personal"
2212accounts = ["Stargate"]
2213
2214[[orgs.projects]]
2215name = "forge"
2216path = "~/Projects/forge"
2217
2218[[accounts]]
2219display_name = "Stargate"
2220token = "t"
2221models = ["claude-sonnet-5"]
2222provider = "anthropic"
2223
2224[documents]
2225max_size_mib = 0
2226"#,
2227 );
2228 let err = load_from_dir(dir.path()).expect_err("0 must not load");
2229 assert!(
2230 err.to_string().contains("max_size_mib = 0"),
2231 "the error names the unusable key, got: {err}",
2232 );
2233 }
2234
2235 #[test]
2242 fn a_document_ceiling_past_the_wire_cap_is_refused_at_load() {
2243 assert_eq!(
2244 DOCUMENT_MAX_MIB_LIMIT, 190,
2245 "the most the 256 MiB frame cap carries once a document crosses base64-encoded",
2246 );
2247 let dir = tempdir().expect("tempdir");
2248 write_config(
2249 dir.path(),
2250 &format!("{}\n[documents]\nmax_size_mib = 191\n", minimal_config()),
2251 );
2252 let err = load_from_dir(dir.path()).expect_err("over the wire cap must not load");
2253 let said = err.to_string();
2254 assert!(said.contains("max_size_mib = 191"), "the error names the value: {said}");
2255 assert!(said.contains("256 MiB"), "and the reason names the frame cap: {said}");
2256
2257 write_config(
2258 dir.path(),
2259 &format!(
2260 "{}\n[documents]\nmax_size_mib = {DOCUMENT_MAX_MIB_LIMIT}\n",
2261 minimal_config()
2262 ),
2263 );
2264 let config = load_from_dir(dir.path()).expect("the limit itself is usable");
2265 assert_eq!(config.document_max_bytes, DOCUMENT_MAX_MIB_LIMIT * 1024 * 1024);
2266 }
2267
2268 #[test]
2271 fn usage_poll_interval_defaults_when_the_section_is_absent() {
2272 let dir = tempdir().expect("tempdir");
2273 write_config(dir.path(), minimal_config());
2274 let config = load_from_dir(dir.path()).expect("absent section loads");
2275 assert_eq!(config.usage_poll_interval, Duration::from_secs(60), "the 60 s default");
2276 }
2277
2278 #[test]
2279 fn usage_poll_interval_parses_from_the_section() {
2280 let dir = tempdir().expect("tempdir");
2281 write_config(dir.path(), &format!("{}\n[usage]\npoll_seconds = 30\n", minimal_config()));
2282 let config = load_from_dir(dir.path()).expect("section loads");
2283 assert_eq!(config.usage_poll_interval, Duration::from_secs(30));
2284 }
2285
2286 #[test]
2289 fn usage_poll_interval_zero_is_refused_at_load() {
2290 let dir = tempdir().expect("tempdir");
2291 write_config(dir.path(), &format!("{}\n[usage]\npoll_seconds = 0\n", minimal_config()));
2292 let err = load_from_dir(dir.path()).expect_err("0 must not load");
2293 assert!(
2294 err.to_string().contains("[usage] poll_seconds"),
2295 "the error names the unusable key, got: {err}",
2296 );
2297 }
2298
2299 #[test]
2300 fn usage_section_rejects_an_unknown_key() {
2301 let dir = tempdir().expect("tempdir");
2302 write_config(dir.path(), &format!("{}\n[usage]\npoll_second = 30\n", minimal_config()));
2303 let err = load_from_dir(dir.path()).expect_err("a near-miss key must not load");
2304 assert!(
2305 err.to_string().contains("poll_second"),
2306 "the error names the offending key, got: {err}",
2307 );
2308 }
2309
2310 #[test]
2313 fn sessions_default_when_the_section_is_absent() {
2314 let dir = tempdir().expect("tempdir");
2315 write_config(dir.path(), minimal_config());
2316 let config = load_from_dir(dir.path()).expect("absent section loads");
2317 assert!(config.sessions.heal, "a durable seat heals unless it is turned off");
2318 assert_eq!(config.sessions.heal_attempts, 3, "three restarts");
2319 assert_eq!(
2320 config.sessions.heal_backoff,
2321 vec![Duration::from_secs(5), Duration::from_secs(30), Duration::from_secs(120)],
2322 "5 s, 30 s then 2 min",
2323 );
2324 assert_eq!(config.sessions.heal_stable, Duration::from_secs(600), "10 minutes");
2325 }
2326
2327 #[test]
2328 fn sessions_section_reads_its_keys() {
2329 let dir = tempdir().expect("tempdir");
2330 write_config(
2331 dir.path(),
2332 &format!(
2333 "{}\n[sessions]\nheal = false\nheal_attempts = 2\nheal_backoff = [\"1s\", \
2334 \"90s\"]\nheal_stable = \"30m\"\n",
2335 minimal_config()
2336 ),
2337 );
2338 let config = load_from_dir(dir.path()).expect("section loads");
2339 assert!(!config.sessions.heal);
2340 assert_eq!(config.sessions.heal_attempts, 2);
2341 assert_eq!(
2342 config.sessions.heal_backoff,
2343 vec![Duration::from_secs(1), Duration::from_secs(90)],
2344 );
2345 assert_eq!(config.sessions.heal_stable, Duration::from_secs(1800));
2346 }
2347
2348 #[test]
2351 fn sessions_backoff_repeats_its_last_interval() {
2352 let dir = tempdir().expect("tempdir");
2353 write_config(
2354 dir.path(),
2355 &format!(
2356 "{}\n[sessions]\nheal_attempts = 3\nheal_backoff = [\"7s\"]\n",
2357 minimal_config()
2358 ),
2359 );
2360 let config = load_from_dir(dir.path()).expect("section loads");
2361 assert_eq!(config.sessions.heal_backoff, vec![Duration::from_secs(7); 3]);
2362 }
2363
2364 #[test]
2367 fn sessions_heal_attempts_zero_is_refused_at_load() {
2368 let dir = tempdir().expect("tempdir");
2369 write_config(dir.path(), &format!("{}\n[sessions]\nheal_attempts = 0\n", minimal_config()));
2370 let err = load_from_dir(dir.path()).expect_err("0 attempts must not load");
2371 assert!(
2372 err.to_string().contains("sessions.heal_attempts"),
2373 "the error names the unusable key, got: {err}",
2374 );
2375 }
2376
2377 #[test]
2380 fn sessions_duration_without_a_unit_is_refused_at_load() {
2381 let dir = tempdir().expect("tempdir");
2382 write_config(
2383 dir.path(),
2384 &format!("{}\n[sessions]\nheal_stable = \"10\"\n", minimal_config()),
2385 );
2386 let err = load_from_dir(dir.path()).expect_err("a bare number must not load");
2387 assert!(
2388 err.to_string().contains("sessions.heal_stable"),
2389 "the error names the unusable key, got: {err}",
2390 );
2391 }
2392
2393 #[test]
2396 fn server_section_defaults_to_enabled_on_loopback() {
2397 let dir = tempdir().expect("tempdir");
2398 write_config(dir.path(), minimal_config());
2399 let config = load_from_dir(dir.path()).expect("absent section loads");
2400 assert!(config.server.enabled, "the listener is on unless it is turned off");
2401 assert_eq!(DEFAULT_SERVER_PORT, 8790, "the number the book's [server] table documents");
2405 assert_eq!(config.server.port, DEFAULT_SERVER_PORT, "an absent section takes that port");
2406 assert_eq!(config.server.bind, IpAddr::V4(Ipv4Addr::LOCALHOST));
2407 }
2408
2409 #[test]
2410 fn server_section_reads_its_keys() {
2411 let dir = tempdir().expect("tempdir");
2412 write_config(
2413 dir.path(),
2414 &format!(
2415 "{}\n[server]\nenabled = false\nport = 9100\nbind = \"10.0.0.5\"\n",
2416 minimal_config()
2417 ),
2418 );
2419 let config = load_from_dir(dir.path()).expect("section loads");
2420 assert!(!config.server.enabled);
2421 assert_eq!(config.server.port, 9100);
2422 assert_eq!(config.server.bind, "10.0.0.5".parse::<IpAddr>().expect("ip"));
2423 }
2424
2425 #[test]
2428 fn client_section_reads_its_keys() {
2429 let dir = tempdir().expect("tempdir");
2430 write_config(
2431 dir.path(),
2432 &format!(
2433 "{}\n[client]\nmark = \"klin\"\ntheme = \"dark\"\nfont = \"system\"\n",
2434 minimal_config()
2435 ),
2436 );
2437 let config = load_from_dir(dir.path()).expect("section loads");
2438 assert_eq!(config.client.mark.as_deref(), Some("klin"));
2439 assert_eq!(config.client.theme.as_deref(), Some("dark"));
2440 assert_eq!(config.client.font.as_deref(), Some("system"));
2441
2442 let dir = tempdir().expect("tempdir");
2443 write_config(dir.path(), minimal_config());
2444 let config = load_from_dir(dir.path()).expect("absent section loads");
2445 assert_eq!(config.client.mark, None, "an unset key is the built-in, not a pinned value");
2446 assert_eq!(config.client.theme, None);
2447 assert_eq!(config.client.font, None, "and the built-in face is what an unset font draws");
2448 }
2449
2450 #[test]
2455 fn browser_section_reads_its_keys_and_refuses_a_bad_range() {
2456 let dir = tempdir().expect("tempdir");
2457 write_config(
2458 dir.path(),
2459 &format!(
2460 "{}\n[browser]\nfps = 30\nquality = 90\nidle_minutes = 45\n",
2461 minimal_config()
2462 ),
2463 );
2464 let config = load_from_dir(dir.path()).expect("section loads");
2465 assert_eq!(config.browser.fps, 30);
2466 assert_eq!(config.browser.quality, 90);
2467 assert_eq!(config.browser.idle_minutes, 45);
2468
2469 let dir = tempdir().expect("tempdir");
2470 write_config(dir.path(), minimal_config());
2471 let config = load_from_dir(dir.path()).expect("absent section loads");
2472 assert_eq!(
2473 config.browser,
2474 BrowserSettings::default(),
2475 "an absent section is the documented defaults",
2476 );
2477 assert_eq!(config.browser.fps, 30, "the documented default frames a second");
2478
2479 for (key, value) in [("fps", "0"), ("fps", "121"), ("quality", "101")] {
2480 let dir = tempdir().expect("tempdir");
2481 write_config(
2482 dir.path(),
2483 &format!("{}\n[browser]\n{key} = {value}\n", minimal_config()),
2484 );
2485 let Err(refused) = load_from_dir(dir.path()) else {
2486 panic!("{key} = {value} is outside its range and must be refused");
2487 };
2488 let rendered = format!("{refused}");
2489 assert!(rendered.contains("[browser]"), "{rendered}");
2490 }
2491 }
2492
2493 #[test]
2497 fn an_org_profile_is_the_fallback_and_a_project_override_wins() {
2498 let dir = tempdir().expect("tempdir");
2499 write_config(
2500 dir.path(),
2501 r#"
2502[[accounts]]
2503display_name = "main"
2504token = "t"
2505models = ["claude-sonnet-5"]
2506provider = "anthropic"
2507
2508[[orgs]]
2509name = "Busytools"
2510accounts = ["main"]
2511browser_profile = "work"
2512
2513 [[orgs.projects]]
2514 name = "forge"
2515 path = "/tmp/forge"
2516
2517 [[orgs.projects]]
2518 name = "apply"
2519 path = "/tmp/apply"
2520 browser_profile = "job"
2521
2522[[orgs]]
2523name = "Personal"
2524accounts = ["main"]
2525
2526 [[orgs.projects]]
2527 name = "notes"
2528 path = "/tmp/notes"
2529"#,
2530 );
2531 let loaded = load_from_dir(dir.path()).expect("the config loads");
2532 let key = |name: &str| {
2533 loaded
2534 .projects
2535 .iter()
2536 .find(|project| project.name == name)
2537 .map(|project| project.browser_profile.as_str().to_owned())
2538 .expect("the project")
2539 };
2540 assert_eq!(key("forge"), "Busytools/work", "the org's profile is the fallback");
2541 assert_eq!(key("apply"), "Busytools/job", "a project override replaces it");
2542 let personal = loaded
2543 .projects
2544 .iter()
2545 .find(|project| project.org == "Personal")
2546 .expect("the personal project");
2547 assert_eq!(personal.browser_profile.as_str(), "Personal/default", "no key is default");
2548 assert_eq!(
2549 loaded.org_browser_profiles.get("Busytools").map(String::as_str),
2550 Some("work"),
2551 "the org map carries what the boot's resolver reads",
2552 );
2553 assert_eq!(
2554 loaded.org_browser_profiles.get("Personal").map(String::as_str),
2555 Some("default"),
2556 );
2557 }
2558
2559 #[test]
2560 fn an_invalid_profile_name_fails_the_load_and_names_the_key() {
2561 let dir = tempdir().expect("tempdir");
2562 write_config(
2563 dir.path(),
2564 r#"
2565[[accounts]]
2566display_name = "main"
2567token = "t"
2568models = ["claude-sonnet-5"]
2569provider = "anthropic"
2570
2571[[orgs]]
2572name = "Busytools"
2573accounts = ["main"]
2574browser_profile = "two words"
2575
2576 [[orgs.projects]]
2577 name = "forge"
2578 path = "/tmp/forge"
2579"#,
2580 );
2581 let why = load_from_dir(dir.path()).expect_err("the load refuses");
2582 let text = format!("{why}");
2583 assert!(text.contains("browser_profile"), "the key is named: {text}");
2584 assert!(text.contains("two words"), "the value is named: {text}");
2585 }
2586
2587 #[test]
2591 fn the_browser_idle_default_is_thirty_minutes_and_grace_is_gone() {
2592 let settings = BrowserSettings::default();
2593 assert_eq!(settings.idle_minutes, 30);
2594 let dir = tempdir().expect("tempdir");
2595 write_config(dir.path(), &format!("{}\n[browser]\ngrace_minutes = 5\n", minimal_config()));
2596 let why = load_from_dir(dir.path()).expect_err("a removed key is refused");
2597 assert!(format!("{why}").contains("grace_minutes"), "the key is named: {why}");
2598 }
2599
2600 #[test]
2606 fn a_disabled_server_section_is_not_validated() {
2607 for port in [0, DEFAULT_GATEWAY_PORT] {
2608 let dir = tempdir().expect("tempdir");
2609 write_config(
2610 dir.path(),
2611 &format!("{}\n[server]\nenabled = false\nport = {port}\n", minimal_config()),
2612 );
2613 let config =
2614 load_from_dir(dir.path()).expect("a disabled listener must not fail the load");
2615 assert!(!config.server.enabled, "the section stays disabled at port {port}");
2616 }
2617
2618 let dir = tempdir().expect("tempdir");
2619 write_config(
2620 dir.path(),
2621 &format!(
2622 "{}\n[server]\nenabled = false\n[client]\nmark = \"anvilish\"\n",
2623 minimal_config()
2624 ),
2625 );
2626 let config = load_from_dir(dir.path()).expect("a disabled listener never reads a name");
2627 assert_eq!(config.client.mark.as_deref(), Some("anvilish"), "the name is carried as given");
2628 }
2629
2630 #[test]
2631 fn a_near_miss_key_fails_loudly_in_both_sections() {
2632 for (section, key) in [("[server]", "portt"), ("[client]", "themes")] {
2633 let err = toml::from_str::<ForgeToml>(&format!("{section}\n{key} = 0\n"))
2634 .expect_err("a near-miss key must fail loudly");
2635 assert!(err.to_string().contains(key), "{section} ignored {key}: {err}");
2636 }
2637 }
2638
2639 #[test]
2642 fn server_port_zero_is_refused_at_load() {
2643 let dir = tempdir().expect("tempdir");
2644 write_config(dir.path(), &format!("{}\n[server]\nport = 0\n", minimal_config()));
2645 let err = load_from_dir(dir.path()).expect_err("port 0 must not load");
2646 assert!(
2647 err.to_string().contains("server port 0"),
2648 "the error names the unusable port, got: {err}",
2649 );
2650 }
2651
2652 #[test]
2656 fn server_port_on_the_gateway_port_is_refused_at_load() {
2657 let dir = tempdir().expect("tempdir");
2658 write_config(
2659 dir.path(),
2660 &format!("{}\n[gateway]\nport = 9100\n\n[server]\nport = 9100\n", minimal_config()),
2661 );
2662 let err = load_from_dir(dir.path()).expect_err("the gateway's port must not load");
2663 let message = err.to_string();
2664 assert!(
2665 message.contains("9100") && message.contains("gateway"),
2666 "the error names the port and what it collides with, got: {message}",
2667 );
2668 }
2669
2670 #[test]
2674 fn a_client_name_outside_the_shipped_set_is_refused_at_load() {
2675 let load = |key: &str, value: &str| {
2676 let dir = tempdir().expect("tempdir");
2677 write_config(
2678 dir.path(),
2679 &format!("{}\n[client]\n{key} = \"{value}\"\n", minimal_config()),
2680 );
2681 let loaded = load_from_dir(dir.path());
2682 (dir, loaded)
2683 };
2684 let named = |config: &crate::config::LoadedConfig, key: &str| match key {
2685 "mark" => config.client.mark.clone(),
2686 "font" => config.client.font.clone(),
2687 _ => config.client.theme.clone(),
2688 };
2689
2690 for (key, shipped, unknown) in
2693 [("mark", "klin", "anvilish"), ("theme", "dark", "light"), ("font", "system", "comic")]
2694 {
2695 let (_dir, loaded) = load(key, shipped);
2696 let config = loaded.expect("a shipped name loads");
2697 assert_eq!(
2698 named(&config, key).as_deref(),
2699 Some(shipped),
2700 "{key} keeps the name it was given",
2701 );
2702
2703 let (_dir, loaded) = load(key, unknown);
2704 let err = loaded.expect_err("a name forge does not ship must not load");
2705 let message = err.to_string();
2706 assert!(
2707 message.contains(key) && message.contains(unknown),
2708 "the error names the key and the name it refused, got: {message}",
2709 );
2710 }
2711 }
2712
2713 #[test]
2717 fn retired_top_level_sections_are_refused() {
2718 let cases = [
2719 ("web", "\n[web]\nenabled = false\nport = 9100\n"),
2720 ("workers", "\n[workers]\nmax_workers = 4\n"),
2721 ("projects", "\n[projects.forge]\nmodel = \"claude-sonnet-5\"\n"),
2722 ("selection", "\n[selection]\npolicy = \"round_robin\"\n"),
2723 ("ui", "\n[ui]\nlaunchpad_spinner = \"braille\"\n"),
2724 (
2725 "resume_lead",
2726 "\n[[orgs.projects]]\nname = \"notes\"\npath = \"/tmp\"\nresume_lead = true\n",
2727 ),
2728 ];
2729 for (label, stanza) in cases {
2730 let dir = tempdir().expect("tempdir");
2731 write_config(dir.path(), &format!("{}{stanza}", minimal_config()));
2732 let error =
2733 load_from_dir(dir.path()).expect_err("a retired key or section must not load");
2734 let message = error.to_string();
2735 assert!(message.contains(label), "the refusal names {label}, got: {message}");
2736 }
2737 }
2738
2739 #[test]
2740 fn gateway_rotation_zero_keys_are_refused_at_load() {
2741 let values = [
2744 ("streak_count", "0"),
2745 ("streak_window_secs", "0"),
2746 ("streak_window_secs", "99999999999"),
2747 ("no_reset_cooldown_secs", "0"),
2748 ("no_reset_cooldown_secs", "99999999999"),
2749 ];
2750 for (key, value) in values {
2751 let dir = tempdir().expect("tempdir");
2752 write_config(
2753 dir.path(),
2754 &format!(
2755 r#"
2756[[orgs]]
2757name = "Personal"
2758accounts = ["Stargate"]
2759[[orgs.projects]]
2760name = "forge"
2761path = "~/Projects/forge"
2762
2763[[accounts]]
2764display_name = "Stargate"
2765token = "t"
2766models = ["claude-sonnet-5"]
2767provider = "anthropic"
2768
2769[gateway]
2770{key} = {value}
2771"#
2772 ),
2773 );
2774 let err = load_from_dir(dir.path()).expect_err("an unusable key must not load");
2775 assert!(err.to_string().contains(key), "the error names the unusable key, got: {err}");
2776 }
2777 }
2778
2779 #[test]
2780 fn parses_the_flat_base_url_and_token_onto_the_provider_variables() {
2781 let dir = tempdir().expect("tempdir");
2782 write_config(
2783 dir.path(),
2784 r#"
2785[[orgs]]
2786name = "Personal"
2787accounts = ["Codex"]
2788[[orgs.projects]]
2789name = "forge"
2790path = "~/Projects/forge"
2791[[accounts]]
2792display_name = "Codex"
2793token = "unused"
2794models = ["claude-sonnet-5"]
2795provider = "codex"
2796base_url = "http://localhost:18765"
2797"#,
2798 );
2799 let config = load_from_dir(dir.path()).expect("happy path");
2800 let account = &config.accounts[0];
2801 assert_eq!(account.display_name, "Codex");
2802 assert_eq!(
2803 account.env.get("ANTHROPIC_BASE_URL").map(String::as_str),
2804 Some("http://localhost:18765"),
2805 "the flat base_url stamps the provider's base-url variable",
2806 );
2807 assert_eq!(
2808 account.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
2809 Some("unused"),
2810 "the flat token stamps the provider's credential variable",
2811 );
2812 }
2813
2814 #[test]
2815 fn account_without_provider_fails_the_load_naming_the_account() {
2816 let dir = tempdir().expect("tempdir");
2817 write_config(
2818 dir.path(),
2819 r#"
2820[[orgs]]
2821name = "Personal"
2822accounts = ["Stargate"]
2823[[orgs.projects]]
2824name = "forge"
2825path = "~/Projects/forge"
2826[[accounts]]
2827display_name = "Stargate"
2828token = "t"
2829models = ["claude-sonnet-5"]
2830"#,
2831 );
2832 let err = load_from_dir(dir.path()).expect_err("absent provider must not load");
2833 let message = err.to_string();
2834 assert!(
2835 message.contains("Stargate"),
2836 "the error has to name the offending account, got: {message}",
2837 );
2838 assert!(
2839 message.contains("anthropic") && message.contains("codex"),
2840 "the error has to list the accepted providers, got: {message}",
2841 );
2842 }
2843
2844 #[test]
2845 fn codex_provider_without_a_base_url_fails_the_load() {
2846 let dir = tempdir().expect("tempdir");
2847 write_config(
2848 dir.path(),
2849 r#"
2850[[orgs]]
2851name = "Personal"
2852accounts = ["Codex"]
2853[[orgs.projects]]
2854name = "forge"
2855path = "~/Projects/forge"
2856[[accounts]]
2857display_name = "Codex"
2858token = "t"
2859models = ["claude-sonnet-5"]
2860provider = "codex"
2861"#,
2862 );
2863 let err = load_from_dir(dir.path()).expect_err("codex without a base url must not load");
2864 let message = err.to_string();
2865 assert!(
2866 message.contains("Codex") && message.contains("base_url"),
2867 "the error has to name the account and the missing key, got: {message}",
2868 );
2869 }
2870
2871 #[test]
2877 fn a_blank_slug_value_fails_the_load() {
2878 let dir = tempdir().expect("tempdir");
2879 write_config(
2880 dir.path(),
2881 r#"
2882[[orgs]]
2883name = "Personal"
2884accounts = ["Codex"]
2885[[orgs.projects]]
2886name = "forge"
2887path = "~/Projects/forge"
2888[[accounts]]
2889display_name = "Codex"
2890token = "t"
2891models = ["claude-sonnet-5"]
2892provider = "codex"
2893base_url = "http://localhost:18765"
2894[accounts.model_slugs]
2895"claude-sonnet-5" = " "
2896"#,
2897 );
2898 let err = load_from_dir(dir.path()).expect_err("a blank slug value must not load");
2899 let message = err.to_string();
2900 assert!(message.contains("blank slug"), "the error names the blank slug, got: {message}");
2901 }
2902
2903 #[test]
2904 fn a_project_model_no_account_declares_fails_the_load() {
2905 let dir = tempdir().expect("tempdir");
2906 write_config(
2907 dir.path(),
2908 r#"
2909[[orgs]]
2910name = "Personal"
2911accounts = ["Codex"]
2912[[orgs.projects]]
2913name = "forge"
2914path = "~/Projects/forge"
2915model = "gpt-5.6-luna"
2916[[accounts]]
2917display_name = "Codex"
2918token = "t"
2919models = ["claude-sonnet-5"]
2920provider = "codex"
2921base_url = "http://localhost:18765"
2922"#,
2923 );
2924 let err = load_from_dir(dir.path()).expect_err("an undeclared project model must not load");
2925 let message = err.to_string();
2926 assert!(
2927 message.contains("gpt-5.6-luna") && message.contains("forge"),
2928 "the error names the project and the model, got: {message}",
2929 );
2930 }
2931
2932 #[test]
2933 fn a_project_model_an_org_account_declares_loads() {
2934 let dir = tempdir().expect("tempdir");
2935 write_config(
2936 dir.path(),
2937 r#"
2938[[orgs]]
2939name = "Personal"
2940accounts = ["Codex"]
2941[[orgs.projects]]
2942name = "forge"
2943path = "~/Projects/forge"
2944model = "claude-sonnet-5"
2945[[accounts]]
2946display_name = "Codex"
2947token = "t"
2948models = ["claude-sonnet-5"]
2949provider = "codex"
2950base_url = "http://localhost:18765"
2951"#,
2952 );
2953 let config = load_from_dir(dir.path()).expect("a declared model loads");
2954 assert_eq!(config.projects[0].model.as_deref(), Some("claude-sonnet-5"));
2955 }
2956
2957 #[test]
2958 fn a_project_model_that_is_an_alias_loads() {
2959 let dir = tempdir().expect("tempdir");
2960 write_config(
2961 dir.path(),
2962 r#"
2963[[orgs]]
2964name = "Personal"
2965accounts = ["Granite"]
2966[[orgs.projects]]
2967name = "forge"
2968path = "~/Projects/forge"
2969model = "claude-opus-5"
2970[[accounts]]
2971display_name = "Granite"
2972token = "t"
2973models = ["claude-opus-5[1m]"]
2974provider = "anthropic"
2975[accounts.model_aliases]
2976"claude-opus-5[1m]" = ["claude-opus-5"]
2977"#,
2978 );
2979 let config = load_from_dir(dir.path()).expect("an aliased project model loads");
2980 assert_eq!(config.projects[0].model.as_deref(), Some("claude-opus-5"));
2981 }
2982
2983 #[test]
2984 fn a_project_model_that_is_neither_declared_nor_an_alias_fails_the_load() {
2985 let dir = tempdir().expect("tempdir");
2986 write_config(
2987 dir.path(),
2988 r#"
2989[[orgs]]
2990name = "Personal"
2991accounts = ["Granite"]
2992[[orgs.projects]]
2993name = "forge"
2994path = "~/Projects/forge"
2995model = "claude-sonnet-5"
2996[[accounts]]
2997display_name = "Granite"
2998token = "t"
2999models = ["claude-opus-5[1m]"]
3000provider = "anthropic"
3001[accounts.model_aliases]
3002"claude-opus-5[1m]" = ["claude-opus-5"]
3003"#,
3004 );
3005 let err = load_from_dir(dir.path())
3006 .expect_err("holding aliases must not make the account serve everything");
3007 let message = err.to_string();
3008 assert!(
3009 message.contains("claude-sonnet-5"),
3010 "the error names the undeclared model, got: {message}",
3011 );
3012 }
3013
3014 #[test]
3015 fn an_alias_for_an_undeclared_model_fails_the_load() {
3016 let dir = tempdir().expect("tempdir");
3017 write_config(
3018 dir.path(),
3019 r#"
3020[[orgs]]
3021name = "Personal"
3022accounts = ["Granite"]
3023[[orgs.projects]]
3024name = "forge"
3025path = "~/Projects/forge"
3026[[accounts]]
3027display_name = "Granite"
3028token = "t"
3029models = ["claude-opus-5[1m]"]
3030provider = "anthropic"
3031[accounts.model_aliases]
3032"claude-opus-5" = ["claude-opus-5[1m]"]
3033"#,
3034 );
3035 let err = load_from_dir(dir.path()).expect_err("an undeclared alias key must not load");
3036 let message = err.to_string();
3037 assert!(
3038 message.contains("'claude-opus-5'"),
3039 "the error quotes the undeclared alias key, not the declared marker, got: {message}",
3040 );
3041 }
3042
3043 #[test]
3044 fn an_empty_alias_list_fails_the_load() {
3045 let dir = tempdir().expect("tempdir");
3046 write_config(
3047 dir.path(),
3048 r#"
3049[[orgs]]
3050name = "Personal"
3051accounts = ["Granite"]
3052[[orgs.projects]]
3053name = "forge"
3054path = "~/Projects/forge"
3055[[accounts]]
3056display_name = "Granite"
3057token = "t"
3058models = ["claude-opus-5[1m]"]
3059provider = "anthropic"
3060[accounts.model_aliases]
3061"claude-opus-5[1m]" = []
3062"#,
3063 );
3064 let err = load_from_dir(dir.path()).expect_err("an empty alias list must not load");
3065 let message = err.to_string();
3066 assert!(
3067 message.contains("empty alias list"),
3068 "the error says the list is empty, got: {message}",
3069 );
3070 }
3071
3072 #[test]
3073 fn openrouter_base_url_without_the_api_suffix_fails_the_load() {
3074 let dir = tempdir().expect("tempdir");
3075 write_config(
3076 dir.path(),
3077 r#"
3078[[orgs]]
3079name = "Personal"
3080accounts = ["Router"]
3081[[orgs.projects]]
3082name = "forge"
3083path = "~/Projects/forge"
3084[[accounts]]
3085display_name = "Router"
3086token = "t"
3087models = ["claude-sonnet-5"]
3088provider = "openrouter"
3089base_url = "https://openrouter.ai"
3090"#,
3091 );
3092 let err = load_from_dir(dir.path()).expect_err("a bare host must not load");
3093 let message = err.to_string();
3094 assert!(
3095 message.contains("Router"),
3096 "the error has to name the offending account, got: {message}",
3097 );
3098 assert!(
3099 message.contains("/api"),
3100 "the error has to say what the base url is expected to end in, got: {message}",
3101 );
3102 }
3103
3104 #[test]
3105 fn openrouter_base_url_with_the_api_suffix_loads() {
3106 let dir = tempdir().expect("tempdir");
3107 write_config(
3108 dir.path(),
3109 r#"
3110[[orgs]]
3111name = "Personal"
3112accounts = ["Router"]
3113[[orgs.projects]]
3114name = "forge"
3115path = "~/Projects/forge"
3116[[accounts]]
3117display_name = "Router"
3118token = "t"
3119models = ["claude-sonnet-5"]
3120provider = "openrouter"
3121base_url = "https://openrouter.ai/api/"
3122"#,
3123 );
3124 load_from_dir(dir.path()).expect("a trailing slash after /api is still the api base");
3125 }
3126
3127 #[test]
3128 fn account_rejects_an_unknown_key() {
3129 let dir = tempdir().expect("tempdir");
3130 write_config(
3131 dir.path(),
3132 r#"
3133[[orgs]]
3134name = "Personal"
3135accounts = ["Stargate"]
3136[[orgs.projects]]
3137name = "forge"
3138path = "~/Projects/forge"
3139[[accounts]]
3140display_name = "Stargate"
3141token = "t"
3142models = ["claude-sonnet-5"]
3143provider = "anthropic"
3144providers = "anthropic"
3145"#,
3146 );
3147 let err = load_from_dir(dir.path()).expect_err("a mistyped account key must not load");
3151 let message = err.to_string();
3152 assert!(
3153 message.contains("providers"),
3154 "the error has to name the offending key, got: {message}",
3155 );
3156 }
3157
3158 #[test]
3159 fn project_entry_rejects_an_unknown_key() {
3160 let dir = tempdir().expect("tempdir");
3161 write_config(
3162 dir.path(),
3163 r#"
3164[[orgs]]
3165name = "Personal"
3166accounts = ["Stargate"]
3167[[orgs.projects]]
3168name = "forge"
3169path = "~/Projects/forge"
3170gatewy = true
3171[[accounts]]
3172display_name = "Stargate"
3173token = "t"
3174models = ["claude-sonnet-5"]
3175provider = "anthropic"
3176"#,
3177 );
3178 let err = load_from_dir(dir.path()).expect_err("a mistyped project key must not load");
3181 let message = err.to_string();
3182 assert!(
3183 message.contains("gatewy"),
3184 "the error has to name the offending key, got: {message}",
3185 );
3186 }
3187
3188 #[test]
3189 fn slack_section_parses_and_rejects_an_unknown_key() {
3190 let toml = r#"
3191[[slack]]
3192workspace = "acme"
3193token = "xoxp-test"
3194poll_seconds = 45
3195dm_poll_seconds = 120
3196thread_idle_days = 30
3197stale_after_hours = 6
3198"#;
3199 let parsed: ForgeToml = toml::from_str(toml).expect("slack section parses");
3200 assert_eq!(parsed.slack.len(), 1);
3201 assert_eq!(parsed.slack[0].workspace, "acme");
3202 assert_eq!(parsed.slack[0].poll_seconds, 45);
3203 assert_eq!(parsed.slack[0].dm_poll_seconds, 120);
3204 assert_eq!(parsed.slack[0].thread_idle_days, 30);
3205 assert_eq!(parsed.slack[0].stale_after_hours, 6);
3206
3207 let bad = r#"
3208[[slack]]
3209workspace = "acme"
3210token = "xoxp-test"
3211poll_second = 45
3212"#;
3213 let err = toml::from_str::<ForgeToml>(bad).expect_err("a near-miss key must fail loudly");
3214 assert!(err.to_string().contains("poll_second"), "got: {err}");
3215 }
3216
3217 #[test]
3218 fn slack_poll_seconds_defaults_to_five() {
3219 let toml = "[[slack]]\nworkspace = \"a\"\ntoken = \"x\"\n";
3220 let parsed: ForgeToml = toml::from_str(toml).expect("parses without poll_seconds");
3221 assert_eq!(parsed.slack[0].poll_seconds, 5);
3222 }
3223
3224 #[test]
3225 fn slack_dm_poll_seconds_defaults_to_a_hundred_and_fifty() {
3226 let toml = "[[slack]]\nworkspace = \"a\"\ntoken = \"x\"\n";
3227 let parsed: ForgeToml = toml::from_str(toml).expect("parses without dm_poll_seconds");
3228 assert_eq!(
3229 parsed.slack[0].dm_poll_seconds, 150,
3230 "the DM class shares the token's allowance with the conversation sweep",
3231 );
3232 }
3233
3234 #[test]
3235 fn slack_thread_idle_days_defaults_to_fourteen() {
3236 let toml = "[[slack]]\nworkspace = \"a\"\ntoken = \"x\"\n";
3237 let parsed: ForgeToml = toml::from_str(toml).expect("parses without thread_idle_days");
3238 assert_eq!(parsed.slack[0].thread_idle_days, 14);
3239 }
3240
3241 #[test]
3242 fn slack_stale_after_hours_defaults_to_twenty_four() {
3243 let toml = "[[slack]]\nworkspace = \"a\"\ntoken = \"x\"\n";
3244 let parsed: ForgeToml = toml::from_str(toml).expect("parses without stale_after_hours");
3245 assert_eq!(parsed.slack[0].stale_after_hours, 24);
3246 }
3247
3248 #[test]
3251 fn load_refuses_a_slack_list_the_boot_would_refuse() {
3252 let dir = tempdir().expect("tempdir");
3253 write_config(
3254 dir.path(),
3255 r#"
3256[[orgs]]
3257name = "Personal"
3258accounts = ["Acct"]
3259[[orgs.projects]]
3260name = "forge"
3261path = "/tmp/forge"
3262[[accounts]]
3263display_name = "Acct"
3264token = "t"
3265models = ["claude-sonnet-5"]
3266provider = "anthropic"
3267[[slack]]
3268workspace = "same"
3269token = "x"
3270[[slack]]
3271workspace = "same"
3272token = "y"
3273"#,
3274 );
3275
3276 let error = load_from_dir(dir.path()).expect_err("duplicate labels are refused");
3277 assert!(matches!(error, WorkspaceError::ConfigInvalid { .. }), "{error}");
3278 assert!(error.to_string().contains("share the label"), "{error}");
3279 }
3280
3281 #[test]
3284 fn a_projects_slack_binding_overrides_its_orgs() {
3285 let dir = tempdir().expect("tempdir");
3286 write_config(
3287 dir.path(),
3288 r#"
3289[[orgs]]
3290name = "Busytools"
3291accounts = ["Acct"]
3292[orgs.slack]
3293workspace = "org-wide"
3294
3295[[orgs.projects]]
3296name = "forge"
3297path = "/tmp/forge"
3298[orgs.projects.slack]
3299workspace = "project-one"
3300
3301[[accounts]]
3302display_name = "Acct"
3303token = "t"
3304models = ["claude-sonnet-5"]
3305provider = "anthropic"
3306[[slack]]
3307workspace = "org-wide"
3308token = "x"
3309[[slack]]
3310workspace = "project-one"
3311token = "y"
3312"#,
3313 );
3314
3315 let config = load_from_dir(dir.path()).expect("both labels exist");
3316 assert_eq!(
3317 named(&config, "forge").slack_workspace.as_deref(),
3318 Some("project-one"),
3319 "the project's binding is the one its sessions resolve",
3320 );
3321 }
3322
3323 #[test]
3325 fn an_org_binding_reaches_a_project_that_names_none() {
3326 let dir = tempdir().expect("tempdir");
3327 write_config(
3328 dir.path(),
3329 r#"
3330[[orgs]]
3331name = "Busytools"
3332accounts = ["Acct"]
3333[orgs.slack]
3334workspace = "org-wide"
3335
3336[[orgs.projects]]
3337name = "forge"
3338path = "/tmp/forge"
3339
3340[[orgs.projects]]
3341name = "busymail"
3342path = "/tmp/busymail"
3343
3344[[accounts]]
3345display_name = "Acct"
3346token = "t"
3347models = ["claude-sonnet-5"]
3348provider = "anthropic"
3349[[slack]]
3350workspace = "org-wide"
3351token = "x"
3352"#,
3353 );
3354
3355 let config = load_from_dir(dir.path()).expect("the org's label exists");
3356 assert_eq!(named(&config, "forge").slack_workspace.as_deref(), Some("org-wide"));
3357 assert_eq!(named(&config, "busymail").slack_workspace.as_deref(), Some("org-wide"));
3358 }
3359
3360 #[test]
3364 fn a_project_binding_naming_an_unknown_label_is_refused_with_both_names() {
3365 let dir = tempdir().expect("tempdir");
3366 write_config(
3367 dir.path(),
3368 r#"
3369[[orgs]]
3370name = "Busytools"
3371accounts = ["Acct"]
3372[[orgs.projects]]
3373name = "forge"
3374path = "/tmp/forge"
3375[orgs.projects.slack]
3376workspace = "typo"
3377
3378[[accounts]]
3379display_name = "Acct"
3380token = "t"
3381models = ["claude-sonnet-5"]
3382provider = "anthropic"
3383[[slack]]
3384workspace = "real-one"
3385token = "x"
3386"#,
3387 );
3388
3389 let error = load_from_dir(dir.path()).expect_err("an unknown label is refused");
3390 let message = error.to_string();
3391 assert!(message.contains("[[orgs.projects]] slack"), "names the scope: {message}");
3392 assert!(message.contains("'typo'"), "names the label it asked for: {message}");
3393 assert!(message.contains("real-one"), "names the labels that exist: {message}");
3394 }
3395
3396 #[test]
3397 fn an_org_binding_naming_an_unknown_label_is_refused_with_both_names() {
3398 let dir = tempdir().expect("tempdir");
3399 write_config(
3400 dir.path(),
3401 r#"
3402[[orgs]]
3403name = "Busytools"
3404accounts = ["Acct"]
3405[orgs.slack]
3406workspace = "typo"
3407[[orgs.projects]]
3408name = "forge"
3409path = "/tmp/forge"
3410
3411[[accounts]]
3412display_name = "Acct"
3413token = "t"
3414models = ["claude-sonnet-5"]
3415provider = "anthropic"
3416[[slack]]
3417workspace = "real-one"
3418token = "x"
3419"#,
3420 );
3421
3422 let error = load_from_dir(dir.path()).expect_err("an unknown label is refused");
3423 let message = error.to_string();
3424 assert!(message.contains("[[orgs]] slack"), "names the scope: {message}");
3425 assert!(message.contains("'typo'"), "names the label it asked for: {message}");
3426 assert!(message.contains("real-one"), "names the labels that exist: {message}");
3427 }
3428
3429 #[test]
3433 fn a_slack_binding_label_is_trimmed_before_it_is_matched() {
3434 let dir = tempdir().expect("tempdir");
3435 write_config(
3436 dir.path(),
3437 r#"
3438[[orgs]]
3439name = "Busytools"
3440accounts = ["Acct"]
3441[[orgs.projects]]
3442name = "forge"
3443path = "/tmp/forge"
3444[orgs.projects.slack]
3445workspace = " acme "
3446
3447[[accounts]]
3448display_name = "Acct"
3449token = "t"
3450models = ["claude-sonnet-5"]
3451provider = "anthropic"
3452[[slack]]
3453workspace = "acme"
3454token = "x"
3455"#,
3456 );
3457
3458 let config = load_from_dir(dir.path()).expect("the padded label matches");
3459 assert_eq!(
3460 named(&config, "forge").slack_workspace.as_deref(),
3461 Some("acme"),
3462 "the stored label is the trimmed one the clients are keyed by",
3463 );
3464 }
3465
3466 #[test]
3470 fn a_binding_with_no_slack_entries_says_none_are_configured() {
3471 let dir = tempdir().expect("tempdir");
3472 write_config(
3473 dir.path(),
3474 r#"
3475[[orgs]]
3476name = "Busytools"
3477accounts = ["Acct"]
3478[[orgs.projects]]
3479name = "forge"
3480path = "/tmp/forge"
3481[orgs.projects.slack]
3482workspace = "acme"
3483
3484[[accounts]]
3485display_name = "Acct"
3486token = "t"
3487models = ["claude-sonnet-5"]
3488provider = "anthropic"
3489"#,
3490 );
3491
3492 let error = load_from_dir(dir.path()).expect_err("there is no label to bind to");
3493 assert!(
3494 error.to_string().contains("configured: none"),
3495 "the message says the file configures no workspace: {error}",
3496 );
3497 }
3498
3499 #[test]
3500 fn a_whitespace_only_base_url_is_not_a_base_url() {
3501 let dir = tempdir().expect("tempdir");
3502 write_config(
3503 dir.path(),
3504 r#"
3505[[orgs]]
3506name = "Personal"
3507accounts = ["Codex"]
3508[[orgs.projects]]
3509name = "forge"
3510path = "~/Projects/forge"
3511[[accounts]]
3512display_name = "Codex"
3513token = "t"
3514models = ["claude-sonnet-5"]
3515provider = "codex"
3516base_url = " "
3517"#,
3518 );
3519 let err =
3520 load_from_dir(dir.path()).expect_err("a blank base url must not satisfy the check");
3521 assert!(err.to_string().contains("base_url"), "got: {err}");
3522 }
3523
3524 #[test]
3525 fn account_without_env_table_stamps_the_derived_credential() {
3526 let dir = tempdir().expect("tempdir");
3527 write_config(dir.path(), minimal_config());
3528 let config = load_from_dir(dir.path()).expect("happy path");
3529 let account = &config.accounts[0];
3530 assert_eq!(
3533 account.env.get("CLAUDE_CODE_OAUTH_TOKEN").map(String::as_str),
3534 Some("t"),
3535 "the flat token lands on the provider's credential variable",
3536 );
3537 assert_eq!(account.env.len(), 1, "nothing else is injected");
3538 }
3539
3540 #[test]
3547 fn a_gateway_key_in_an_env_layer_never_reaches_the_account_env() {
3548 let dir = tempdir().expect("tempdir");
3549 write_config(
3550 dir.path(),
3551 r#"
3552[env]
3553ANTHROPIC_BASE_URL = "https://proxy.example"
3554ANTHROPIC_API_KEY = "sk-global"
3555
3556[[orgs]]
3557name = "Personal"
3558accounts = ["Personal", "Scratch"]
3559[[orgs.projects]]
3560name = "forge"
3561path = "~/Projects/forge"
3562[[accounts]]
3563display_name = "Personal"
3564token = "t"
3565models = ["claude-sonnet-5"]
3566provider = "anthropic"
3567[accounts.env]
3568ANTHROPIC_AUTH_TOKEN = "t2"
3569ANTHROPIC_API_KEY = " "
3570[[accounts]]
3571display_name = "Scratch"
3572token = "t3"
3573models = ["claude-sonnet-5"]
3574provider = "codex"
3575base_url = "http://localhost:18765"
3576"#,
3577 );
3578 let config = load_from_dir(dir.path()).expect("a gateway env key is not refused");
3579 let personal = config.accounts.iter().find(|a| a.display_name == "Personal").expect("one");
3580 for key in ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] {
3581 assert!(
3582 !personal.env.contains_key(key),
3583 "{key} must not survive into the account env the pool and the forward leg read",
3584 );
3585 }
3586 assert_eq!(
3587 personal.env.get("CLAUDE_CODE_OAUTH_TOKEN").map(String::as_str),
3588 Some("t"),
3589 "the flat token still lands on the account's own variable",
3590 );
3591 let scratch = config.accounts.iter().find(|a| a.display_name == "Scratch").expect("one");
3594 assert_eq!(
3595 scratch.env.get("ANTHROPIC_BASE_URL").map(String::as_str),
3596 Some("http://localhost:18765"),
3597 "the flat base_url is mapped after the scrub, so it still lands",
3598 );
3599 }
3600
3601 #[test]
3606 fn a_credential_never_reaches_the_config_debug_output() {
3607 let toml = r#"
3608[env]
3609MY_GLOBAL_SECRET = "global-supersecret"
3610
3611[[orgs]]
3612name = "Personal"
3613accounts = ["Stargate"]
3614
3615[[orgs.projects]]
3616name = "forge"
3617path = "~/Projects/forge"
3618[orgs.projects.env]
3619CLAUDE_CODE_OAUTH_TOKEN = "project-supersecret"
3620
3621[[accounts]]
3622display_name = "Stargate"
3623token = "account-supersecret"
3624models = ["claude-sonnet-5"]
3625provider = "anthropic"
3626[accounts.env]
3627MY_ACCOUNT_SECRET = "account-env-supersecret"
3628
3629[gotify]
3630url = "https://notifier.example"
3631client_token = "gotify-supersecret"
3632
3633[systemone]
3634base_url = "https://api.typesafe.ai"
3635api_key = "systemone-supersecret"
3636model = "jev-latest"
3637"#;
3638 let dir = tempdir().expect("tempdir");
3639 write_config(dir.path(), toml);
3640 let config = load_from_dir(dir.path()).expect("the config loads");
3641 let parsed: ForgeToml = toml::from_str(toml).expect("the raw config parses");
3642 for rendered in [format!("{config:?}"), format!("{parsed:?}")] {
3643 for (carrier, secret) in [
3644 ("the global env value", "global-supersecret"),
3645 ("the account env value", "account-env-supersecret"),
3646 ("the account's credential stamp", "account-supersecret"),
3647 ("a project env value", "project-supersecret"),
3648 ("the gotify client token", "gotify-supersecret"),
3649 ("the systemone api key", "systemone-supersecret"),
3650 ] {
3651 assert!(!rendered.contains(secret), "{carrier} leaked: {rendered}");
3652 }
3653 for (carrier, readable) in [
3654 ("the org name", "Personal"),
3655 ("the account name", "Stargate"),
3656 ("the project name", "forge"),
3657 ("the gotify url", "notifier.example"),
3658 ("the systemone base url", "api.typesafe.ai"),
3659 ] {
3660 assert!(rendered.contains(readable), "{carrier} must stay readable: {rendered}");
3661 }
3662 }
3663 }
3664
3665 #[test]
3666 fn an_account_without_a_token_fails_the_load() {
3667 let dir = tempdir().expect("tempdir");
3668 write_config(
3669 dir.path(),
3670 r#"
3671[[orgs]]
3672name = "Personal"
3673accounts = ["Codex"]
3674[[orgs.projects]]
3675name = "forge"
3676path = "~/Projects/forge"
3677[[accounts]]
3678display_name = "Codex"
3679models = ["claude-sonnet-5"]
3680provider = "codex"
3681"#,
3682 );
3683 let err = load_from_dir(dir.path()).expect_err("a tokenless account must not load");
3684 let message = err.to_string();
3685 assert!(
3686 message.contains("Codex") && message.contains("token"),
3687 "the error names the account and the missing key, got: {message}",
3688 );
3689 }
3690
3691 #[test]
3692 fn an_account_without_models_fails_the_load() {
3693 let dir = tempdir().expect("tempdir");
3694 write_config(
3695 dir.path(),
3696 r#"
3697[[orgs]]
3698name = "Personal"
3699accounts = ["Codex"]
3700[[orgs.projects]]
3701name = "forge"
3702path = "~/Projects/forge"
3703[[accounts]]
3704display_name = "Codex"
3705token = "t"
3706provider = "codex"
3707"#,
3708 );
3709 let err = load_from_dir(dir.path()).expect_err("a modelless account must not load");
3710 let message = err.to_string();
3711 assert!(
3712 message.contains("models"),
3713 "the error names the missing declaration, got: {message}",
3714 );
3715 }
3716
3717 #[test]
3718 fn a_slug_for_an_undeclared_model_fails_the_load() {
3719 let dir = tempdir().expect("tempdir");
3720 write_config(
3721 dir.path(),
3722 r#"
3723[[orgs]]
3724name = "Personal"
3725accounts = ["Codex"]
3726[[orgs.projects]]
3727name = "forge"
3728path = "~/Projects/forge"
3729[[accounts]]
3730display_name = "Codex"
3731token = "t"
3732models = ["claude-sonnet-5"]
3733provider = "codex"
3734base_url = "http://localhost:18765"
3735[accounts.model_slugs]
3736"deepseek/deepseek-v4.1-flash" = "deepseek/deepseek-v4.1-flash"
3737"#,
3738 );
3739 let err = load_from_dir(dir.path()).expect_err("an undeclared slug key must not load");
3740 let message = err.to_string();
3741 assert!(
3742 message.contains("deepseek/deepseek-v4.1-flash"),
3743 "the error names the undeclared slug key, got: {message}",
3744 );
3745 }
3746
3747 #[test]
3748 fn project_permission_mode_defaults_to_auto_when_absent() {
3749 let dir = tempdir().expect("tempdir");
3750 write_config(dir.path(), minimal_config());
3751 let config = load_from_dir(dir.path()).expect("absent key must not block the load");
3752 assert_eq!(
3753 config.projects[0].permission_mode,
3754 PermissionMode::Auto,
3755 "a project with no per-project keys at all still lands on auto",
3756 );
3757 }
3758
3759 #[test]
3760 fn project_permission_mode_defaults_to_auto_when_the_table_omits_it() {
3761 let dir = tempdir().expect("tempdir");
3762 write_config(
3763 dir.path(),
3764 r#"
3765[[orgs]]
3766name = "Personal"
3767accounts = ["Stargate"]
3768[[orgs.projects]]
3769name = "forge"
3770path = "~/Projects/forge"
3771max_workers = 2
3772[[accounts]]
3773display_name = "Stargate"
3774token = "t"
3775models = ["claude-sonnet-5"]
3776provider = "anthropic"
3777"#,
3778 );
3779 let config = load_from_dir(dir.path()).expect("a table without the key loads");
3780 assert_eq!(
3781 config.projects[0].permission_mode,
3782 PermissionMode::Auto,
3783 "a project whose table omits the key gets auto, not the launcher default",
3784 );
3785 }
3786
3787 #[test]
3788 fn project_permission_mode_parses_an_explicit_value() {
3789 let dir = tempdir().expect("tempdir");
3790 write_config(
3791 dir.path(),
3792 r#"
3793[[orgs]]
3794name = "Personal"
3795accounts = ["Stargate"]
3796[[orgs.projects]]
3797name = "forge"
3798path = "~/Projects/forge"
3799permission_mode = "bypassPermissions"
3800[[accounts]]
3801display_name = "Stargate"
3802token = "t"
3803models = ["claude-sonnet-5"]
3804provider = "anthropic"
3805"#,
3806 );
3807 let config = load_from_dir(dir.path()).expect("happy path");
3808 assert_eq!(
3809 config.projects[0].permission_mode,
3810 PermissionMode::BypassPermissions,
3811 "a valid mode lands on the LoadedProject verbatim",
3812 );
3813 }
3814
3815 #[test]
3816 fn project_permission_mode_accepts_the_snake_case_alias() {
3817 let dir = tempdir().expect("tempdir");
3818 write_config(
3819 dir.path(),
3820 r#"
3821[[orgs]]
3822name = "Personal"
3823accounts = ["Stargate"]
3824[[orgs.projects]]
3825name = "forge"
3826path = "~/Projects/forge"
3827permission_mode = "bypass_permissions"
3828[[accounts]]
3829display_name = "Stargate"
3830token = "t"
3831models = ["claude-sonnet-5"]
3832provider = "anthropic"
3833"#,
3834 );
3835 let config = load_from_dir(dir.path()).expect("the from_wire aliases still work");
3836 assert_eq!(
3837 config.projects[0].permission_mode,
3838 PermissionMode::BypassPermissions,
3839 "the alias set must not shrink now the key moved tables",
3840 );
3841 }
3842
3843 #[test]
3844 fn project_with_invalid_permission_mode_fails_naming_the_accepted_set() {
3845 let dir = tempdir().expect("tempdir");
3846 write_config(
3847 dir.path(),
3848 r#"
3849[[orgs]]
3850name = "Personal"
3851accounts = ["Stargate"]
3852[[orgs.projects]]
3853name = "forge"
3854path = "~/Projects/forge"
3855permission_mode = "yolo"
3856[[accounts]]
3857display_name = "Stargate"
3858token = "t"
3859models = ["claude-sonnet-5"]
3860provider = "anthropic"
3861"#,
3862 );
3863 let err = load_from_dir(dir.path()).expect_err("an invalid mode must not load");
3864 let message = err.to_string();
3865 assert!(
3866 message.contains("yolo") && message.contains("forge"),
3867 "the error has to name the offending value and project, got: {message}",
3868 );
3869 assert!(
3870 message.contains("bypassPermissions") && message.contains("acceptEdits"),
3871 "the error has to list the accepted values, got: {message}",
3872 );
3873 }
3874
3875 #[test]
3876 fn mistyped_permission_mode_key_is_still_rejected() {
3877 let dir = tempdir().expect("tempdir");
3878 write_config(
3879 dir.path(),
3880 r#"
3881[[orgs]]
3882name = "Personal"
3883accounts = ["Stargate"]
3884[[orgs.projects]]
3885name = "forge"
3886path = "~/Projects/forge"
3887permissionmode = "bypassPermissions"
3888[[accounts]]
3889display_name = "Stargate"
3890token = "t"
3891models = ["claude-sonnet-5"]
3892provider = "anthropic"
3893"#,
3894 );
3895 let err = load_from_dir(dir.path()).expect_err("a mistyped project key must not load");
3896 assert!(
3897 err.to_string().contains("permissionmode"),
3898 "deny_unknown_fields has to catch the near-miss, got: {err}",
3899 );
3900 }
3901
3902 #[test]
3907 fn mistyped_keys_are_rejected_in_every_section() {
3908 let base = minimal_config();
3909 let cases = [
3911 ("top level", format!("mistyped = 1\n{base}"), "mistyped"),
3912 (
3913 "an org entry",
3914 base.replacen(
3915 "accounts = [\"Stargate\"]",
3916 "accounts = [\"Stargate\"]\nfallback_account = \"Stargate\"",
3917 1,
3918 ),
3919 "fallback_account",
3920 ),
3921 (
3922 "[gotify]",
3923 format!("{base}\n[gotify]\nurl = \"https://notifier\"\nclient_tokens = \"t\"\n"),
3924 "client_tokens",
3925 ),
3926 (
3927 "[systemone]",
3928 format!(
3929 "{base}\n[systemone]\nendpoint = \"https://api.typesafe.ai\"\nbase_url = \"https://api.typesafe.ai\"\nmodel = \"jev-latest\"\n"
3930 ),
3931 "endpoint",
3932 ),
3933 (
3934 "[client]",
3935 format!("{base}\n[client]\nbrowser_idle_minutes = 45\n"),
3936 "browser_idle_minutes",
3937 ),
3938 ];
3939 let mut failures: Vec<String> = Vec::new();
3943 for (label, text, needle) in cases {
3944 let dir = tempdir().expect("tempdir");
3945 write_config(dir.path(), &text);
3946 match load_from_dir(dir.path()) {
3947 Ok(_) => failures.push(format!("{label}: loaded with `{needle}` in it")),
3948 Err(err) if !err.to_string().contains(needle) => {
3949 failures.push(format!("{label}: error does not name `{needle}`: {err}"));
3950 }
3951 Err(_) => {}
3952 }
3953 }
3954 assert!(
3955 failures.is_empty(),
3956 "every near-miss is refused and named:\n{}",
3957 failures.join("\n")
3958 );
3959 }
3960
3961 #[test]
3962 fn parses_top_level_env_table() {
3963 let raw =
3964 format!("[env]\nCLAUDE_CODE_AUTO_COMPACT_WINDOW = \"950000\"\n{}", minimal_config());
3965 let parsed: ForgeToml = toml::from_str(&raw).expect("parse top-level [env]");
3966 assert_eq!(
3967 parsed.env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW").map(String::as_str),
3968 Some("950000"),
3969 );
3970 }
3971
3972 #[test]
3973 fn absent_top_level_env_table_is_empty() {
3974 let parsed: ForgeToml = toml::from_str(minimal_config()).expect("parse without [env]");
3975 assert!(parsed.env.is_empty(), "no [env] -> empty map");
3976 }
3977
3978 #[test]
3979 fn global_env_lands_in_account_without_its_own_env() {
3980 let dir = tempdir().expect("tempdir");
3981 write_config(
3982 dir.path(),
3983 r#"
3984[env]
3985CLAUDE_CODE_AUTO_COMPACT_WINDOW = "950000"
3986[[orgs]]
3987name = "Personal"
3988accounts = ["Stargate"]
3989[[orgs.projects]]
3990name = "forge"
3991path = "~/Projects/forge"
3992[[accounts]]
3993display_name = "Stargate"
3994token = "t"
3995models = ["claude-sonnet-5"]
3996provider = "anthropic"
3997"#,
3998 );
3999 let config = load_from_dir(dir.path()).expect("happy path");
4000 let account = &config.accounts[0];
4001 assert_eq!(
4002 account.env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW").map(String::as_str),
4003 Some("950000"),
4004 "global [env] key merged into an account with no [accounts.env]",
4005 );
4006 }
4007
4008 #[test]
4009 fn account_env_overrides_global_env_per_key() {
4010 let dir = tempdir().expect("tempdir");
4011 write_config(
4012 dir.path(),
4013 r#"
4014[env]
4015CLAUDE_CODE_AUTO_COMPACT_WINDOW = "950000"
4016[[orgs]]
4017name = "Personal"
4018accounts = ["Codex", "Gateway"]
4019[[orgs.projects]]
4020name = "forge"
4021path = "~/Projects/forge"
4022[[accounts]]
4023display_name = "Codex"
4024token = "t"
4025models = ["claude-sonnet-5"]
4026provider = "anthropic"
4027[accounts.env]
4028CLAUDE_CODE_AUTO_COMPACT_WINDOW = "372000"
4029[[accounts]]
4030display_name = "Gateway"
4031token = "t"
4032models = ["claude-sonnet-5"]
4033provider = "anthropic"
4034"#,
4035 );
4036 let config = load_from_dir(dir.path()).expect("happy path");
4037 let codex = config.accounts.iter().find(|a| a.display_name == "Codex").expect("Codex");
4038 let gateway =
4039 config.accounts.iter().find(|a| a.display_name == "Gateway").expect("Gateway");
4040 assert_eq!(
4041 codex.env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW").map(String::as_str),
4042 Some("372000"),
4043 "per-account [accounts.env] overrides the global [env] key",
4044 );
4045 assert_eq!(
4046 gateway.env.get("CLAUDE_CODE_AUTO_COMPACT_WINDOW").map(String::as_str),
4047 Some("950000"),
4048 "an account with no override inherits the global [env] key",
4049 );
4050 }
4051
4052 #[test]
4053 fn whitespace_padded_setup_token_is_trimmed_once_at_load() {
4054 let dir = tempdir().expect("tempdir");
4055 write_config(
4056 dir.path(),
4057 r#"
4058[[orgs]]
4059name = "Personal"
4060accounts = ["Stargate"]
4061[[orgs.projects]]
4062name = "forge"
4063path = "~/Projects/forge"
4064
4065[orgs.projects.env]
4066CLAUDE_CODE_OAUTH_TOKEN = " sk-ant-oat01-project "
4067
4068[[accounts]]
4069display_name = "Stargate"
4070token = " sk-ant-oat01-stargate "
4071models = ["claude-sonnet-5"]
4072provider = "anthropic"
4073"#,
4074 );
4075 let config = load_from_dir(dir.path()).expect("happy path");
4076 let account = &config.accounts[0];
4077 assert_eq!(
4081 account.env.get("CLAUDE_CODE_OAUTH_TOKEN").map(String::as_str),
4082 Some("sk-ant-oat01-stargate"),
4083 "the setup token is trimmed where it enters the config",
4084 );
4085 assert_eq!(
4086 forge_gateway::token_bearer(&account.env),
4087 Some("sk-ant-oat01-stargate"),
4088 "the probe reads the same trimmed credential the child gets",
4089 );
4090 assert_eq!(
4091 config.projects[0].env.get("CLAUDE_CODE_OAUTH_TOKEN").map(String::as_str),
4092 Some("sk-ant-oat01-project"),
4093 "the project env layer is trimmed at load too - it reaches the child unmerged",
4094 );
4095 }
4096
4097 #[test]
4098 fn no_global_env_leaves_account_env_untouched() {
4099 let dir = tempdir().expect("tempdir");
4100 write_config(
4101 dir.path(),
4102 r#"
4103[[orgs]]
4104name = "Personal"
4105accounts = ["Codex"]
4106[[orgs.projects]]
4107name = "forge"
4108path = "~/Projects/forge"
4109[[accounts]]
4110display_name = "Codex"
4111token = "t"
4112models = ["claude-sonnet-5"]
4113provider = "codex"
4114base_url = "http://localhost:18765"
4115"#,
4116 );
4117 let config = load_from_dir(dir.path()).expect("happy path");
4118 let account = &config.accounts[0];
4119 assert_eq!(account.env.len(), 2, "no [env] -> env is exactly the derived stamps");
4120 assert_eq!(
4121 account.env.get("ANTHROPIC_BASE_URL").map(String::as_str),
4122 Some("http://localhost:18765"),
4123 );
4124 assert_eq!(account.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str), Some("t"),);
4125 }
4126
4127 fn precedence_config() -> &'static str {
4131 r#"
4132[env]
4133ALL_THREE = "global"
4134GLOBAL_PROJECT = "global"
4135GLOBAL_ONLY = "global"
4136
4137[[orgs]]
4138name = "Personal"
4139accounts = ["Codex"]
4140[[orgs.projects]]
4141name = "forge"
4142path = "~/Projects/forge"
4143env = { ALL_THREE = "project", GLOBAL_PROJECT = "project", PROJECT_ONLY = "project" }
4144
4145[[accounts]]
4146display_name = "Codex"
4147token = "t"
4148models = ["claude-sonnet-5"]
4149provider = "anthropic"
4150[accounts.env]
4151ALL_THREE = "account"
4152"#
4153 }
4154
4155 fn precedence_env() -> HashMap<String, String> {
4157 let dir = tempdir().expect("tempdir");
4158 write_config(dir.path(), precedence_config());
4159 let config = load_from_dir(dir.path()).expect("precedence fixture loads");
4160 let account = &config.accounts[0];
4161 session_env(named(&config, "forge"), &account.env)
4162 }
4163
4164 #[test]
4169 fn near_miss_env_declarations_are_rejected() {
4170 let cases = [("mistyped inner table", "envs = { K = \"v\" }\n", "envs")];
4172 for (label, stanza, needle) in cases {
4173 let dir = tempdir().expect("tempdir");
4174 write_config(dir.path(), &format!("{}\n{stanza}", minimal_config()));
4175 let msg = load_from_dir(dir.path()).expect_err(label).to_string();
4176 assert!(msg.contains(needle), "{label}: error must name `{needle}`, got: {msg}");
4177 }
4178 }
4179
4180 fn config_with_env_file(contents: &str, inline: &str) -> tempfile::TempDir {
4183 let dir = tempdir().expect("tempdir");
4184 let file = dir.path().join("secrets.env");
4185 fs::write(&file, contents).expect("write env file");
4186 let inline_line =
4189 if inline.is_empty() { String::new() } else { format!("\nenv = {{ {inline} }}") };
4190 write_config(
4191 dir.path(),
4192 &format!(
4193 r#"
4194[[orgs]]
4195name = "Personal"
4196accounts = ["Stargate"]
4197[[orgs.projects]]
4198name = "forge"
4199path = "~/Projects/forge"
4200env_file = "{file}"{inline_line}
4201[[accounts]]
4202display_name = "Stargate"
4203token = "t"
4204models = ["claude-sonnet-5"]
4205provider = "anthropic"
4206"#,
4207 file = file.display()
4208 ),
4209 );
4210 dir
4211 }
4212
4213 #[test]
4214 fn env_file_entries_join_the_project_env() {
4215 let dir = config_with_env_file(
4216 "# a comment\n\nAIRMAIL_TOKEN = tok-from-file\nQUOTED = \"in-quotes\"\n\
4217 SINGLE = 'in-singles'\nUNMATCHED = \"dangling\n",
4218 "",
4219 );
4220 let config = load_from_dir(dir.path()).expect("happy path");
4221 let env = &config.projects[0].env;
4222 assert_eq!(
4223 env.get("AIRMAIL_TOKEN").map(String::as_str),
4224 Some("tok-from-file"),
4225 "comments and blank lines skipped, the key lands",
4226 );
4227 assert_eq!(env.get("QUOTED").map(String::as_str), Some("in-quotes"));
4229 assert_eq!(env.get("SINGLE").map(String::as_str), Some("in-singles"));
4230 assert_eq!(
4231 env.get("UNMATCHED").map(String::as_str),
4232 Some("\"dangling"),
4233 "an unmatched quote is part of the value, not a delimiter",
4234 );
4235 }
4236
4237 #[test]
4238 fn the_inline_table_wins_over_the_env_file_per_key() {
4239 let dir = config_with_env_file(
4240 "SHARED = from-file\nFILE_ONLY = from-file\n",
4241 "SHARED = \"from-inline\"",
4242 );
4243 let config = load_from_dir(dir.path()).expect("happy path");
4244 let env = &config.projects[0].env;
4245 assert_eq!(env.get("SHARED").map(String::as_str), Some("from-inline"));
4246 assert_eq!(env.get("FILE_ONLY").map(String::as_str), Some("from-file"));
4247 }
4248
4249 #[test]
4250 fn a_missing_env_file_leaves_the_project_env_empty_and_still_loads() {
4251 let dir = tempdir().expect("tempdir");
4252 write_config(
4253 dir.path(),
4254 &format!(
4255 r#"
4256[[orgs]]
4257name = "Personal"
4258accounts = ["Stargate"]
4259[[orgs.projects]]
4260name = "forge"
4261path = "~/Projects/forge"
4262env_file = "{}/nope.env"
4263[[accounts]]
4264display_name = "Stargate"
4265token = "t"
4266models = ["claude-sonnet-5"]
4267provider = "anthropic"
4268"#,
4269 dir.path().display()
4270 ),
4271 );
4272 let config = load_from_dir(dir.path()).expect("a missing env file must not fail the load");
4273 assert!(config.projects[0].env.is_empty(), "and contributes no keys");
4274 }
4275
4276 #[test]
4277 fn a_malformed_line_is_skipped_and_the_rest_applies() {
4278 let dir = config_with_env_file("GOOD = yes\nthis line has no equals\nALSO = fine\n", "");
4279 let config = load_from_dir(dir.path()).expect("happy path");
4280 let env = &config.projects[0].env;
4281 assert_eq!(env.get("GOOD").map(String::as_str), Some("yes"));
4282 assert_eq!(env.get("ALSO").map(String::as_str), Some("fine"));
4283 assert_eq!(env.len(), 2, "only the malformed line is dropped: {env:?}");
4284 }
4285
4286 #[test]
4287 fn parses_project_env_table() {
4288 let dir = tempdir().expect("tempdir");
4289 write_config(
4290 dir.path(),
4291 r#"
4292[[orgs]]
4293name = "Personal"
4294accounts = ["Stargate"]
4295[[orgs.projects]]
4296name = "forge"
4297path = "~/Projects/forge"
4298env = { AIRMAIL_MCP_URL = "https://mail.example/mcp" }
4299[[accounts]]
4300display_name = "Stargate"
4301token = "t"
4302models = ["claude-sonnet-5"]
4303provider = "anthropic"
4304"#,
4305 );
4306 let config = load_from_dir(dir.path()).expect("happy path");
4307 let project = &config.projects[0];
4308 assert_eq!(
4309 project.env.get("AIRMAIL_MCP_URL").map(String::as_str),
4310 Some("https://mail.example/mcp"),
4311 "the project env block lands on the named project",
4312 );
4313 }
4314
4315 #[test]
4323 fn project_env_wins_each_precedence_boundary() {
4324 let env = precedence_env();
4325 assert_eq!(
4326 env.get("ALL_THREE").map(String::as_str),
4327 Some("project"),
4328 "a key in all three layers resolves to the project value",
4329 );
4330 assert_eq!(
4331 env.get("GLOBAL_PROJECT").map(String::as_str),
4332 Some("project"),
4333 "global + project, account silent -> project wins",
4334 );
4335 assert_eq!(
4336 env.get("PROJECT_ONLY").map(String::as_str),
4337 Some("project"),
4338 "a project-only key reaches the session",
4339 );
4340 assert_eq!(
4341 env.get("GLOBAL_ONLY").map(String::as_str),
4342 Some("global"),
4343 "and the global layer still arrives, so the above are overrides not absences",
4344 );
4345 }
4346
4347 #[test]
4352 fn seat_env_carries_the_servers_own_environment_below_the_config_tables() {
4353 let dir = tempdir().expect("tempdir");
4354 write_config(dir.path(), precedence_config());
4355 let config = load_from_dir(dir.path()).expect("precedence fixture loads");
4356 let account = &config.accounts[0];
4357 let env = seat_env(named(&config, "forge"), &account.env);
4358 let home = std::env::var("HOME").expect("the test process has a HOME");
4359 assert_eq!(env.get("HOME"), Some(&home), "the server's own HOME survives");
4360 assert_eq!(
4361 env.get("ALL_THREE").map(String::as_str),
4362 Some("project"),
4363 "and the project's table still overrides it",
4364 );
4365 }
4366
4367 #[test]
4370 fn one_projects_env_does_not_reach_another_on_the_same_account() {
4371 let dir = tempdir().expect("tempdir");
4372 write_config(
4373 dir.path(),
4374 r#"
4375[[orgs]]
4376name = "Personal"
4377accounts = ["Codex"]
4378[[orgs.projects]]
4379name = "forge"
4380path = "~/Projects/forge"
4381env = { AIRMAIL_TOKEN = "forge-only-secret" }
4382[[orgs.projects]]
4383name = "airmail"
4384path = "~/Projects/airmail"
4385
4386[[accounts]]
4387display_name = "Codex"
4388token = "t"
4389models = ["claude-sonnet-5"]
4390provider = "codex"
4391base_url = "http://localhost:18765"
4392"#,
4393 );
4394 let config = load_from_dir(dir.path()).expect("happy path");
4395 let account = &config.accounts[0];
4396
4397 let forge_env = session_env(named(&config, "forge"), &account.env);
4398 assert_eq!(
4399 forge_env.get("AIRMAIL_TOKEN").map(String::as_str),
4400 Some("forge-only-secret"),
4401 "the declaring project gets its own key",
4402 );
4403
4404 let airmail_env = session_env(named(&config, "airmail"), &account.env);
4405 assert!(
4406 !airmail_env.contains_key("AIRMAIL_TOKEN"),
4407 "another project on the SAME account must not receive it, got: {airmail_env:?}",
4408 );
4409 assert_eq!(
4410 airmail_env, account.env,
4411 "a project declaring no env gets exactly the account env, nothing borrowed",
4412 );
4413 }
4414
4415 #[test]
4421 fn two_block_form_envs_attach_to_their_own_entries() {
4422 let dir = tempdir().expect("tempdir");
4423 write_config(
4424 dir.path(),
4425 r#"
4426[[orgs]]
4427name = "Personal"
4428accounts = ["Codex"]
4429[[orgs.projects]]
4430name = "forge"
4431path = "~/Projects/forge"
4432
4433[orgs.projects.env]
4434FORGE_KEY = "forge-secret"
4435[[orgs.projects]]
4436name = "airmail"
4437path = "~/Projects/airmail"
4438
4439[orgs.projects.env]
4440AIRMAIL_KEY = "airmail-secret"
4441[[accounts]]
4442display_name = "Codex"
4443token = "t"
4444models = ["claude-sonnet-5"]
4445provider = "codex"
4446base_url = "http://localhost:18765"
4447"#,
4448 );
4449 let config = load_from_dir(dir.path()).expect("happy path");
4450 let forge = config.projects.iter().find(|p| p.name == "forge").expect("forge");
4451 let airmail = config.projects.iter().find(|p| p.name == "airmail").expect("airmail");
4452 assert_eq!(
4453 forge.env.get("FORGE_KEY").map(String::as_str),
4454 Some("forge-secret"),
4455 "the first entry's env block attaches to the first project",
4456 );
4457 assert!(
4458 !forge.env.contains_key("AIRMAIL_KEY"),
4459 "the second block must not leak onto the first project: {:?}",
4460 forge.env,
4461 );
4462 assert_eq!(
4463 airmail.env.get("AIRMAIL_KEY").map(String::as_str),
4464 Some("airmail-secret"),
4465 "the second entry's env block attaches to the second project",
4466 );
4467 assert!(
4468 !airmail.env.contains_key("FORGE_KEY"),
4469 "the first block must not leak onto the second project: {:?}",
4470 airmail.env,
4471 );
4472 }
4473
4474 #[test]
4475 fn parses_gotify_block() {
4476 let dir = tempdir().expect("tempdir");
4477 let raw = format!(
4478 "{}\n[gotify]\nurl = \"https://g.example\"\nclient_token = \"Cabc\"\n",
4479 minimal_config()
4480 );
4481 write_config(dir.path(), &raw);
4482 let config = load_from_dir(dir.path()).expect("happy path");
4483 assert_eq!(
4484 config.gotify,
4485 Some(forge_primitives::GotifyConfig {
4486 url: "https://g.example".to_owned(),
4487 client_token: "Cabc".to_owned(),
4488 })
4489 );
4490 }
4491
4492 #[test]
4497 fn the_dictate_section_reaches_the_loaded_config() {
4498 let dir = tempdir().expect("tempdir");
4499 write_config(
4500 dir.path(),
4501 &format!("{}\n[dictate]\nenabled = true\nlanguage = \"en\"\n", minimal_config()),
4502 );
4503 let config = load_from_dir(dir.path()).expect("happy path");
4504 assert!(config.dictate.enabled, "an explicit `enabled = true` must survive the load");
4505 assert_eq!(config.dictate.language.as_deref(), Some("en"));
4506
4507 let bare = tempdir().expect("tempdir");
4508 write_config(bare.path(), minimal_config());
4509 let config = load_from_dir(bare.path()).expect("happy path");
4510 assert!(!config.dictate.enabled, "an absent section must leave dictation off");
4511 }
4512
4513 #[test]
4516 fn the_tts_model_and_voice_reach_the_loaded_config() {
4517 let dir = tempdir().expect("tempdir");
4518 write_config(
4519 dir.path(),
4520 &format!(
4521 "{}\n[tts]\nspeaking_model = \"hexgrad/Kokoro-82M\"\nvoice = \"bf_emma\"\n",
4522 minimal_config()
4523 ),
4524 );
4525 let config = load_from_dir(dir.path()).expect("happy path");
4526 assert_eq!(config.tts.speaking_model.as_deref(), Some("hexgrad/Kokoro-82M"));
4527 assert_eq!(config.tts.voice.as_deref(), Some("bf_emma"));
4528
4529 let unknown = tempdir().expect("tempdir");
4530 write_config(
4531 unknown.path(),
4532 &format!("{}\n[tts]\nspeaking_model = \"hexgrad/Kokoro-90M\"\n", minimal_config()),
4533 );
4534 let error = load_from_dir(unknown.path()).expect_err("an unknown model fails the load");
4535 let text = error.to_string();
4536 assert!(text.contains("hexgrad/Kokoro-90M"), "got: {text}");
4537 assert!(text.contains("speaking_model"), "got: {text}");
4538 }
4539
4540 #[test]
4541 fn absent_gotify_block_is_none() {
4542 let dir = tempdir().expect("tempdir");
4543 write_config(dir.path(), minimal_config());
4544 let config = load_from_dir(dir.path()).expect("happy path");
4545 assert_eq!(config.gotify, None);
4546 }
4547
4548 #[test]
4549 fn parses_systemone_block() {
4550 let dir = tempdir().expect("tempdir");
4551 let raw = format!(
4552 "{}\n[systemone]\nbase_url = \"https://api.typesafe.ai\"\napi_key = \"ts-key\"\nmodel = \"jev-latest\"\ntimeout_ms = 15000\n",
4553 minimal_config()
4554 );
4555 write_config(dir.path(), &raw);
4556 let config = load_from_dir(dir.path()).expect("happy path");
4557 assert_eq!(
4558 config.systemone,
4559 Some(forge_system_one::SystemOneConfig {
4560 base_url: "https://api.typesafe.ai".to_owned(),
4561 api_key: Some("ts-key".to_owned()),
4562 model: "jev-latest".to_owned(),
4563 timeout_ms: 15_000,
4564 })
4565 );
4566 }
4567
4568 #[test]
4569 fn absent_systemone_block_is_none() {
4570 let dir = tempdir().expect("tempdir");
4571 write_config(dir.path(), minimal_config());
4572 let config = load_from_dir(dir.path()).expect("happy path");
4573 assert_eq!(config.systemone, None);
4574 }
4575
4576 #[test]
4577 fn disabled_systemone_block_is_none() {
4578 let dir = tempdir().expect("tempdir");
4579 let raw = format!(
4580 "{}\n[systemone]\nenabled = false\nbase_url = \"https://api.typesafe.ai\"\nmodel = \"jev-latest\"\n",
4581 minimal_config()
4582 );
4583 write_config(dir.path(), &raw);
4584 let config = load_from_dir(dir.path()).expect("a disabled section loads");
4585 assert_eq!(config.systemone, None);
4586 }
4587
4588 #[test]
4589 fn enabled_systemone_without_base_url_is_refused() {
4590 let dir = tempdir().expect("tempdir");
4591 write_config(
4592 dir.path(),
4593 &format!("{}\n[systemone]\nmodel = \"jev-latest\"\n", minimal_config()),
4594 );
4595 let err = load_from_dir(dir.path()).expect_err("an enabled section needs a base_url");
4596 assert!(err.to_string().contains("[systemone] `base_url` is required"), "{err}");
4597 }
4598
4599 #[test]
4600 fn systemone_without_model_is_refused() {
4601 let dir = tempdir().expect("tempdir");
4602 write_config(
4603 dir.path(),
4604 &format!("{}\n[systemone]\nbase_url = \"https://api.typesafe.ai\"\n", minimal_config()),
4605 );
4606 let err = load_from_dir(dir.path()).expect_err("an enabled section needs a model");
4607 assert!(err.to_string().contains("[systemone] `model` is required"), "{err}");
4608 }
4609
4610 #[test]
4611 fn zero_systemone_timeout_is_refused() {
4612 let dir = tempdir().expect("tempdir");
4613 let raw = format!(
4614 "{}\n[systemone]\nbase_url = \"https://api.typesafe.ai\"\nmodel = \"jev-latest\"\ntimeout_ms = 0\n",
4615 minimal_config()
4616 );
4617 write_config(dir.path(), &raw);
4618 let err = load_from_dir(dir.path()).expect_err("a zero timeout is refused");
4619 assert!(
4620 err.to_string().contains("[systemone] `timeout_ms` must be greater than zero"),
4621 "{err}"
4622 );
4623 }
4624
4625 #[test]
4628 fn the_vault_config_debug_redacts_the_email() {
4629 let config = VaultConfig {
4630 url: "https://vault.example.com".to_owned(),
4631 email: "me@example.com".to_owned(),
4632 idle_minutes: None,
4633 };
4634
4635 let text = format!("{config:?}");
4636
4637 assert!(!text.contains("me@example.com"), "{text}");
4638 assert!(text.contains("https://vault.example.com"), "{text}");
4639 }
4640
4641 #[test]
4642 fn parses_a_vault_block() {
4643 let dir = tempdir().expect("tempdir");
4644 write_config(
4645 dir.path(),
4646 &format!(
4647 "{}\n[vault]\nurl = \"https://bitwarden.hub\"\nemail = \"hi@vedhavyas.dev\"\nidle_minutes = 30\n",
4648 minimal_config()
4649 ),
4650 );
4651 let config = load_from_dir(dir.path()).expect("a complete section loads");
4652 let vault = config.vault.expect("the section reaches the loaded config");
4653 assert_eq!(vault.url, "https://bitwarden.hub");
4654 assert_eq!(vault.email, "hi@vedhavyas.dev");
4655 assert_eq!(vault.idle_minutes, Some(30));
4656 }
4657
4658 #[test]
4659 fn an_absent_idle_bound_is_no_idle_lock() {
4660 let dir = tempdir().expect("tempdir");
4661 write_config(
4662 dir.path(),
4663 &format!(
4664 "{}\n[vault]\nurl = \"https://bitwarden.hub\"\nemail = \"hi@vedhavyas.dev\"\n",
4665 minimal_config()
4666 ),
4667 );
4668 let config = load_from_dir(dir.path()).expect("a complete section loads");
4669 assert_eq!(config.vault.expect("the section reaches the loaded config").idle_minutes, None,);
4670 }
4671
4672 #[test]
4673 fn a_zero_idle_bound_is_refused() {
4674 let dir = tempdir().expect("tempdir");
4675 write_config(
4676 dir.path(),
4677 &format!(
4678 "{}\n[vault]\nurl = \"https://bitwarden.hub\"\nemail = \"hi@vedhavyas.dev\"\nidle_minutes = 0\n",
4679 minimal_config()
4680 ),
4681 );
4682 let err = load_from_dir(dir.path()).expect_err("zero would lock instantly");
4683 assert!(err.to_string().contains("`idle_minutes` must be at least 1"), "{err}");
4684 }
4685
4686 #[test]
4687 fn absent_vault_block_is_none() {
4688 let dir = tempdir().expect("tempdir");
4689 write_config(dir.path(), minimal_config());
4690 let config = load_from_dir(dir.path()).expect("happy path");
4691 assert_eq!(config.vault, None, "an absent section injects no vault tools");
4692 }
4693
4694 #[test]
4695 fn a_plain_http_vault_url_is_refused() {
4696 let dir = tempdir().expect("tempdir");
4697 write_config(
4698 dir.path(),
4699 &format!(
4700 "{}\n[vault]\nurl = \"http://bitwarden.hub\"\nemail = \"hi@vedhavyas.dev\"\n",
4701 minimal_config()
4702 ),
4703 );
4704 let err = load_from_dir(dir.path()).expect_err("http is refused");
4705 assert!(err.to_string().contains("https://"), "{err}");
4706 }
4707
4708 #[test]
4709 fn vault_without_url_is_refused() {
4710 let dir = tempdir().expect("tempdir");
4711 write_config(
4712 dir.path(),
4713 &format!("{}\n[vault]\nemail = \"hi@vedhavyas.dev\"\n", minimal_config()),
4714 );
4715 let err = load_from_dir(dir.path()).expect_err("a section needs a url");
4716 assert!(err.to_string().contains("[vault] `url` is required"), "{err}");
4717 }
4718
4719 #[test]
4720 fn vault_without_email_is_refused() {
4721 let dir = tempdir().expect("tempdir");
4722 write_config(
4723 dir.path(),
4724 &format!("{}\n[vault]\nurl = \"https://bitwarden.hub\"\n", minimal_config()),
4725 );
4726 let err = load_from_dir(dir.path()).expect_err("a section needs an email");
4727 assert!(err.to_string().contains("[vault] `email` is required"), "{err}");
4728 }
4729
4730 #[test]
4731 fn the_plugins_section_reaches_the_loaded_config() {
4732 let dir = tempdir().expect("tempdir");
4733 write_config(dir.path(), &format!("{}\n[plugins]\nauto_update = true\n", minimal_config()));
4734 let config = load_from_dir(dir.path()).expect("happy path");
4735 assert!(config.plugins.auto_update);
4736 }
4737
4738 #[test]
4739 fn a_stale_plugins_key_fails_the_load() {
4740 let dir = tempdir().expect("tempdir");
4741 write_config(
4742 dir.path(),
4743 &format!(
4744 "{}\n[plugins]\nauto_update = true\ntrusted_marketplaces = \
4745 [\"claude-plugins-official\"]\n",
4746 minimal_config()
4747 ),
4748 );
4749 assert!(
4750 load_from_dir(dir.path()).is_err(),
4751 "removed keys are rejected, not silently ignored"
4752 );
4753 }
4754
4755 #[test]
4756 fn absent_plugins_section_auto_update_is_off() {
4757 let dir = tempdir().expect("tempdir");
4758 write_config(dir.path(), minimal_config());
4759 let config = load_from_dir(dir.path()).expect("happy path");
4760 assert!(!config.plugins.auto_update);
4761 }
4762
4763 #[test]
4764 fn an_unknown_plugins_key_fails_the_load() {
4765 let dir = tempdir().expect("tempdir");
4766 write_config(
4767 dir.path(),
4768 &format!("{}\n[plugins]\ntrust_markets = [\"x\"]\n", minimal_config()),
4769 );
4770 let error = load_from_dir(dir.path()).expect_err("unknown key must fail loudly");
4771 assert!(error.to_string().contains("trust_markets"), "names the key: {error}");
4772 }
4773
4774 #[test]
4775 fn the_projects_max_workers_key_reaches_the_loaded_project() {
4776 let dir = tempdir().expect("tempdir");
4777 write_config(
4778 dir.path(),
4779 r#"
4780[[orgs]]
4781name = "Personal"
4782accounts = ["Stargate"]
4783
4784[[orgs.projects]]
4785name = "forge"
4786path = "~/Projects/forge"
4787max_workers = 4
4788
4789[[accounts]]
4790display_name = "Stargate"
4791token = "t"
4792models = ["claude-sonnet-5"]
4793provider = "anthropic"
4794"#,
4795 );
4796 let config = load_from_dir(dir.path()).expect("happy path");
4797 assert_eq!(config.default_project().max_workers, Some(4));
4798 }
4799
4800 #[test]
4801 fn absent_max_workers_leaves_the_project_at_the_default() {
4802 let dir = tempdir().expect("tempdir");
4803 write_config(dir.path(), minimal_config());
4804 let config = load_from_dir(dir.path()).expect("happy path");
4805 assert_eq!(config.default_project().max_workers, None);
4806 }
4807
4808 #[test]
4809 fn a_negative_max_workers_fails_the_load() {
4810 let dir = tempdir().expect("tempdir");
4811 write_config(
4812 dir.path(),
4813 r#"
4814[[orgs]]
4815name = "Personal"
4816accounts = ["Stargate"]
4817
4818[[orgs.projects]]
4819name = "forge"
4820path = "~/Projects/forge"
4821max_workers = -1
4822
4823[[accounts]]
4824display_name = "Stargate"
4825token = "t"
4826models = ["claude-sonnet-5"]
4827provider = "anthropic"
4828"#,
4829 );
4830 let error = load_from_dir(dir.path()).expect_err("a bad value must fail loudly");
4831 assert!(error.to_string().contains("max_workers"), "names the key: {error}");
4832 }
4833
4834 #[test]
4835 fn forge_data_dir_is_the_forge_subfolder() {
4836 let dir = tempdir().expect("tempdir");
4837 assert_eq!(forge_data_dir(dir.path()), dir.path().join("forge"));
4838 }
4839
4840 #[test]
4841 fn ensure_forge_data_dir_creates_the_subfolder() {
4842 let dir = tempdir().expect("tempdir");
4843 let created = ensure_forge_data_dir(dir.path()).expect("create forge/");
4844 assert_eq!(created, dir.path().join("forge"));
4845 assert!(created.is_dir(), "forge/ exists after ensure");
4846 }
4847
4848 #[test]
4849 fn parses_minimal_config() {
4850 let dir = tempdir().expect("tempdir");
4851 write_config(dir.path(), minimal_config());
4852 let config = load_from_dir(dir.path()).expect("happy path");
4853 assert_eq!(config.projects.len(), 1);
4854 assert_eq!(config.default_project().name, "forge");
4855 assert_eq!(config.default_project().org, "Personal");
4856 assert_eq!(config.default_project().accounts, vec!["Stargate"]);
4857 }
4858
4859 #[test]
4860 fn missing_file_returns_config_missing() {
4861 let dir = tempdir().expect("tempdir");
4862 let err = load_from_dir(dir.path()).expect_err("missing should error");
4863 assert!(matches!(err, WorkspaceError::ConfigMissing { .. }));
4864 }
4865
4866 #[test]
4867 fn reads_forge_toml_from_forge_subfolder() {
4868 let dir = tempdir().expect("tempdir");
4869 write_config(dir.path(), minimal_config());
4870 let config = load_from_dir(dir.path()).expect("loads from forge/");
4871 assert_eq!(config.default_project().name, "forge");
4872 }
4873
4874 #[test]
4875 fn malformed_toml_returns_config_parse() {
4876 let dir = tempdir().expect("tempdir");
4877 write_config(dir.path(), "not valid = = toml");
4878 let err = load_from_dir(dir.path()).expect_err("malformed should error");
4879 assert!(matches!(err, WorkspaceError::ConfigParse { .. }));
4880 }
4881
4882 #[test]
4889 fn a_parse_failure_never_prints_the_value() {
4890 let dir = tempdir().expect("tempdir");
4891 write_config(
4892 dir.path(),
4893 "[[accounts]]\ndisplay_name = \"Stargate\"\nclient_tokenn = \"hunter2secret\"\n",
4894 );
4895 let err = load_from_dir(dir.path()).expect_err("the near-miss key fails loudly");
4896 let said = err.to_string();
4897 assert!(said.contains("client_tokenn"), "the key still names what to fix: {said}");
4898 assert!(!said.contains("hunter2secret"), "the value on the line never prints: {said}");
4899 assert!(said.contains("line 3"), "the position says where: {said}");
4900 }
4901
4902 #[test]
4907 fn a_type_mismatch_never_prints_the_value_either() {
4908 let dir = tempdir().expect("tempdir");
4909 write_config(dir.path(), "[server]\nport = \"hunter2secret\"\n");
4910 let err = load_from_dir(dir.path()).expect_err("a string port fails to parse");
4911 let said = err.to_string();
4912 assert!(!said.contains("hunter2secret"), "the mismatched value never prints: {said}");
4913 assert!(said.contains("invalid type"), "and the reason the parse failed survives: {said}");
4914 assert!(said.contains("line 2"), "with the position: {said}");
4915 }
4916
4917 #[test]
4923 fn the_position_counts_characters_and_names_only_a_key_line() {
4924 assert_eq!(parse_position("port = \"x\"\n", Some(8..9)), " at line 1, column 9 in `port`");
4925 assert_eq!(parse_position("caf\u{e9} = 1\n", Some(8..9)), " at line 1, column 8",);
4928 assert_eq!(parse_position("[[accounts]]\n", Some(2..3)), " at line 1, column 3");
4931 assert_eq!(parse_position("\"sk-secret=\" = 1\n", Some(15..16)), " at line 1, column 16",);
4932 }
4933
4934 #[test]
4937 fn the_scrub_drops_an_unterminated_tail() {
4938 assert_eq!(
4939 scrub_quoted("invalid type: string \"hunter2secret"),
4940 "invalid type: string [redacted]",
4941 );
4942 }
4943
4944 #[test]
4945 fn no_orgs_errors() {
4946 let dir = tempdir().expect("tempdir");
4947 write_config(
4948 dir.path(),
4949 r#"
4950[[accounts]]
4951display_name = "Stargate"
4952token = "t"
4953models = ["claude-sonnet-5"]
4954provider = "anthropic"
4955"#,
4956 );
4957 let err = load_from_dir(dir.path()).expect_err("missing orgs should error");
4958 assert!(matches!(err, WorkspaceError::NoOrgsConfigured { .. }));
4959 }
4960
4961 #[test]
4962 fn empty_org_errors() {
4963 let dir = tempdir().expect("tempdir");
4964 write_config(
4965 dir.path(),
4966 r#"
4967[[orgs]]
4968name = "Empty"
4969accounts = ["Stargate"]
4970
4971[[accounts]]
4972display_name = "Stargate"
4973token = "t"
4974models = ["claude-sonnet-5"]
4975provider = "anthropic"
4976"#,
4977 );
4978 let err = load_from_dir(dir.path()).expect_err("org without projects should error");
4979 assert!(matches!(err, WorkspaceError::EmptyOrg { org, .. } if org == "Empty"));
4980 }
4981
4982 #[test]
4983 fn empty_org_accounts_errors() {
4984 let dir = tempdir().expect("tempdir");
4985 write_config(
4986 dir.path(),
4987 r#"
4988[[orgs]]
4989name = "Personal"
4990accounts = []
4991
4992[[orgs.projects]]
4993name = "forge"
4994path = "~/Projects/forge"
4995
4996[[accounts]]
4997display_name = "Stargate"
4998token = "t"
4999models = ["claude-sonnet-5"]
5000provider = "anthropic"
5001"#,
5002 );
5003 let err = load_from_dir(dir.path()).expect_err("empty accounts should error");
5004 assert!(matches!(err, WorkspaceError::EmptyOrgAccounts { org, .. } if org == "Personal"));
5005 }
5006
5007 #[test]
5008 fn unknown_org_account_errors() {
5009 let dir = tempdir().expect("tempdir");
5010 write_config(
5011 dir.path(),
5012 r#"
5013[[orgs]]
5014name = "Personal"
5015accounts = ["Stargate", "Bogus"]
5016
5017[[orgs.projects]]
5018name = "forge"
5019path = "~/Projects/forge"
5020
5021[[accounts]]
5022display_name = "Stargate"
5023token = "t"
5024models = ["claude-sonnet-5"]
5025provider = "anthropic"
5026"#,
5027 );
5028 let err = load_from_dir(dir.path()).expect_err("unknown account should error");
5029 match err {
5030 WorkspaceError::UnknownOrgAccount { org, account, .. } => {
5031 assert_eq!(org, "Personal");
5032 assert_eq!(account, "Bogus");
5033 }
5034 other => panic!("expected UnknownOrgAccount, got {other:?}"),
5035 }
5036 }
5037
5038 #[test]
5039 fn org_fallback_accounts_parse_and_inherit_to_projects() {
5040 let dir = tempdir().expect("tempdir");
5041 write_config(
5042 dir.path(),
5043 r#"
5044[[orgs]]
5045name = "Tiered"
5046accounts = ["Codex"]
5047fallback_accounts = ["Router"]
5048
5049[[orgs.projects]]
5050name = "forge"
5051path = "~/Projects/forge"
5052
5053[[orgs]]
5054name = "Plain"
5055accounts = ["Codex"]
5056
5057[[orgs.projects]]
5058name = "spare"
5059path = "~/Projects/spare"
5060
5061[[accounts]]
5062display_name = "Codex"
5063token = "t"
5064models = ["claude-sonnet-5"]
5065provider = "anthropic"
5066
5067[[accounts]]
5068display_name = "Router"
5069token = "t"
5070models = ["claude-sonnet-5"]
5071provider = "anthropic"
5072"#,
5073 );
5074 let config = load_from_dir(dir.path()).expect("fallback_accounts parse");
5075 let forge = named(&config, "forge");
5076 assert_eq!(forge.fallback_accounts, vec!["Router"]);
5077 let spare = named(&config, "spare");
5078 assert!(
5079 spare.fallback_accounts.is_empty(),
5080 "absent fallback_accounts -> empty vec, not an error",
5081 );
5082 }
5083
5084 #[test]
5085 fn unknown_fallback_account_errors() {
5086 let dir = tempdir().expect("tempdir");
5087 write_config(
5088 dir.path(),
5089 r#"
5090[[orgs]]
5091name = "Personal"
5092accounts = ["Stargate"]
5093fallback_accounts = ["Bogus"]
5094
5095[[orgs.projects]]
5096name = "forge"
5097path = "~/Projects/forge"
5098
5099[[accounts]]
5100display_name = "Stargate"
5101token = "t"
5102models = ["claude-sonnet-5"]
5103provider = "anthropic"
5104"#,
5105 );
5106 let err = load_from_dir(dir.path()).expect_err("unknown fallback should error");
5107 match err {
5108 WorkspaceError::UnknownOrgAccount { org, account, .. } => {
5109 assert_eq!(org, "Personal");
5110 assert_eq!(account, "Bogus");
5111 }
5112 other => panic!("expected UnknownOrgAccount, got {other:?}"),
5113 }
5114 }
5115
5116 #[test]
5117 fn duplicate_org_name_errors() {
5118 let dir = tempdir().expect("tempdir");
5119 write_config(
5120 dir.path(),
5121 r#"
5122[[orgs]]
5123name = "Personal"
5124accounts = ["Stargate"]
5125[[orgs.projects]]
5126name = "forge"
5127path = "~/Projects/forge"
5128
5129[[orgs]]
5130name = "Personal"
5131accounts = ["Stargate"]
5132[[orgs.projects]]
5133name = "aware"
5134path = "~/Projects/aware"
5135
5136[[accounts]]
5137display_name = "Stargate"
5138token = "t"
5139models = ["claude-sonnet-5"]
5140provider = "anthropic"
5141"#,
5142 );
5143 let err = load_from_dir(dir.path()).expect_err("duplicate org should error");
5144 assert!(matches!(err, WorkspaceError::DuplicateOrg { name, .. } if name == "Personal"));
5145 }
5146
5147 #[test]
5148 fn duplicate_project_name_errors() {
5149 let dir = tempdir().expect("tempdir");
5150 write_config(
5151 dir.path(),
5152 r#"
5153[[orgs]]
5154name = "Stargate"
5155accounts = ["Stargate"]
5156[[orgs.projects]]
5157name = "forge"
5158path = "~/Projects/stargate-forge"
5159
5160[[orgs]]
5161name = "Personal"
5162accounts = ["Stargate"]
5163[[orgs.projects]]
5164name = "forge"
5165path = "~/Projects/forge"
5166
5167[[accounts]]
5168display_name = "Stargate"
5169token = "t"
5170models = ["claude-sonnet-5"]
5171provider = "anthropic"
5172"#,
5173 );
5174 let err = load_from_dir(dir.path()).expect_err("duplicate project should error");
5175 assert!(matches!(err, WorkspaceError::DuplicateProject { name, .. } if name == "forge"));
5176 }
5177
5178 #[test]
5179 fn default_project_picks_the_alphabetically_first_project() {
5180 let dir = tempdir().expect("tempdir");
5181 write_config(
5182 dir.path(),
5183 r#"
5184[[orgs]]
5185name = "Personal"
5186accounts = ["Stargate"]
5187[[orgs.projects]]
5188name = "zebra"
5189path = "~/Projects/zebra"
5190[[orgs.projects]]
5191name = "alpha"
5192path = "~/Projects/alpha"
5193[[accounts]]
5194display_name = "Stargate"
5195token = "t"
5196models = ["claude-sonnet-5"]
5197provider = "anthropic"
5198"#,
5199 );
5200 let config = load_from_dir(dir.path()).expect("happy path");
5201 assert_eq!(config.default_project().name, "alpha");
5202 }
5203
5204 #[test]
5207 fn a_stale_auto_start_key_is_refused_by_name() {
5208 let dir = tempdir().expect("tempdir");
5209 write_config(
5210 dir.path(),
5211 r#"
5212[[orgs]]
5213name = "Personal"
5214accounts = ["Stargate"]
5215[[orgs.projects]]
5216name = "forge"
5217path = "~/Projects/forge"
5218auto_start = true
5219[[accounts]]
5220display_name = "Stargate"
5221token = "t"
5222models = ["claude-sonnet-5"]
5223provider = "anthropic"
5224"#,
5225 );
5226 let error = load_from_dir(dir.path()).expect_err("the old key is no longer read");
5227 let rendered = format!("{error}");
5228 assert!(
5229 rendered.contains("auto_start"),
5230 "the refusal names the key the user has to rename: {rendered}",
5231 );
5232 }
5233
5234 #[test]
5235 fn missing_accounts_returns_no_accounts_configured() {
5236 let dir = tempdir().expect("tempdir");
5237 write_config(
5238 dir.path(),
5239 r#"
5240[[orgs]]
5241name = "Personal"
5242accounts = ["Stargate"]
5243[[orgs.projects]]
5244name = "forge"
5245path = "~/Projects/forge"
5246"#,
5247 );
5248 let err = load_from_dir(dir.path()).expect_err("missing accounts should error");
5249 assert!(matches!(err, WorkspaceError::NoAccountsConfigured { .. }));
5250 }
5251
5252 #[test]
5256 fn parses_the_models_table_by_name() {
5257 let dir = tempdir().expect("tempdir");
5258 write_config(
5259 dir.path(),
5260 r#"
5261[[orgs]]
5262name = "Personal"
5263accounts = ["Stargate"]
5264[[orgs.projects]]
5265name = "forge"
5266path = "~/Projects/forge"
5267model = "deepseek-v4.1-flash"
5268
5269[[accounts]]
5270display_name = "Stargate"
5271token = "t"
5272models = ["deepseek-v4.1-flash"]
5273provider = "openrouter"
5274base_url = "https://openrouter.ai/api"
5275
5276[models."deepseek-v4.1-flash"]
5277auto_compact_window = 983040
5278"#,
5279 );
5280 let config = load_from_dir(dir.path()).expect("the table loads");
5281 let parameters =
5282 config.models.get("deepseek-v4.1-flash").expect("the model's table is keyed by name");
5283 assert_eq!(parameters.auto_compact_window, Some(983_040), "the window parses as declared");
5284
5285 let bare = tempdir().expect("tempdir");
5286 write_config(
5287 bare.path(),
5288 r#"
5289[[orgs]]
5290name = "Personal"
5291accounts = ["Stargate"]
5292[[orgs.projects]]
5293name = "forge"
5294path = "~/Projects/forge"
5295
5296[[accounts]]
5297display_name = "Stargate"
5298token = "t"
5299models = ["claude-sonnet-5"]
5300provider = "anthropic"
5301"#,
5302 );
5303 let config = load_from_dir(bare.path()).expect("no table loads too");
5304 assert!(config.models.is_empty(), "an absent [models] table loads as no parameters");
5305 }
5306
5307 #[test]
5311 fn a_non_positive_auto_compact_window_errors() {
5312 let dir = tempdir().expect("tempdir");
5313 write_config(
5314 dir.path(),
5315 r#"
5316[[orgs]]
5317name = "Personal"
5318accounts = ["Stargate"]
5319[[orgs.projects]]
5320name = "forge"
5321path = "~/Projects/forge"
5322
5323[[accounts]]
5324display_name = "Stargate"
5325token = "t"
5326models = ["deepseek-v4.1-flash"]
5327provider = "openrouter"
5328base_url = "https://openrouter.ai/api"
5329
5330[models."deepseek-v4.1-flash"]
5331auto_compact_window = 0
5332"#,
5333 );
5334 let err = load_from_dir(dir.path()).expect_err("a zero window should error");
5335 let message = err.to_string();
5336 assert!(
5337 matches!(
5338 err,
5339 WorkspaceError::ModelAutoCompactWindowInvalid { ref model, value: 0, .. }
5340 if model == "deepseek-v4.1-flash"
5341 ),
5342 "the refusal names the model, got: {message}",
5343 );
5344 assert!(
5345 message.contains("auto_compact_window")
5346 && message.contains("positive window in tokens"),
5347 "and says what to fix, got: {message}",
5348 );
5349 }
5350
5351 #[test]
5355 fn a_project_handoff_table_carries_only_its_own_keys() {
5356 let dir = tempdir().expect("tempdir");
5357 write_config(
5358 dir.path(),
5359 r#"
5360[[orgs]]
5361name = "Personal"
5362accounts = ["Stargate"]
5363
5364[[orgs.projects]]
5365name = "forge"
5366path = "~/Projects/forge"
5367[orgs.projects.handoff]
5368enabled = false
5369
5370[[orgs.projects]]
5371name = "companies"
5372path = "~/Projects/companies"
5373
5374[[accounts]]
5375display_name = "Stargate"
5376token = "t"
5377models = ["claude-sonnet-5"]
5378provider = "anthropic"
5379
5380[handoff]
5381at_percent = 95
5382"#,
5383 );
5384 let config = load_from_dir(dir.path()).expect("the tables load");
5385 let forge = named(&config, "forge").handoff.expect("forge carries a table");
5386 assert_eq!(forge.enabled, Some(false), "its own key is read");
5387 assert_eq!(forge.at_percent, None, "and the key it omits stays absent");
5388 assert_eq!(forge.after_compactions, None, "so the global value shows through");
5389 assert!(
5390 named(&config, "companies").handoff.is_none(),
5391 "a project with no table inherits rather than carrying an empty one",
5392 );
5393 assert_eq!(config.handoff.at_percent, Some(95), "the top-level table is read");
5394 assert_eq!(config.handoff.enabled, None, "and defaults by absence");
5395 }
5396
5397 #[test]
5401 fn a_handoff_percent_outside_the_window_is_refused() {
5402 for value in [0u32, 101, 300] {
5403 let dir = tempdir().expect("tempdir");
5404 write_config(
5405 dir.path(),
5406 &minimal_config().replace(
5407 "[[accounts]]",
5408 &format!("[handoff]\nat_percent = {value}\n\n[[accounts]]"),
5409 ),
5410 );
5411 let err = load_from_dir(dir.path()).expect_err("the share is refused");
5412 let message = err.to_string();
5413 assert!(
5414 matches!(
5415 err,
5416 WorkspaceError::HandoffAtPercentInvalid { value: refused, ref scope, .. }
5417 if refused == value && scope == "the top level"
5418 ),
5419 "the refusal names the table and the value, got: {message}",
5420 );
5421 assert!(
5422 message.contains("between 1 and 100") && message.contains("85% default"),
5423 "and says what to fix, got: {message}",
5424 );
5425 }
5426 }
5427
5428 #[test]
5431 fn a_project_handoff_backstop_of_zero_is_refused() {
5432 let dir = tempdir().expect("tempdir");
5433 write_config(
5434 dir.path(),
5435 &minimal_config().replace(
5436 "[[accounts]]",
5437 "[orgs.projects.handoff]\nafter_compactions = 0\n\n[[accounts]]",
5438 ),
5439 );
5440 let err = load_from_dir(dir.path()).expect_err("the backstop is refused");
5441 let message = err.to_string();
5442 assert!(
5443 matches!(
5444 err,
5445 WorkspaceError::HandoffAfterCompactionsInvalid { ref scope, .. }
5446 if scope == "project \"forge\""
5447 ),
5448 "the refusal names the project's table, got: {message}",
5449 );
5450 assert!(
5451 message.contains("at least 1") && message.contains("1 default"),
5452 "and says what to fix, got: {message}",
5453 );
5454 }
5455
5456 #[test]
5457 fn duplicate_account_display_name_errors() {
5458 let dir = tempdir().expect("tempdir");
5459 write_config(
5460 dir.path(),
5461 r#"
5462[[orgs]]
5463name = "Personal"
5464accounts = ["Stargate"]
5465[[orgs.projects]]
5466name = "forge"
5467path = "~/Projects/forge"
5468
5469[[accounts]]
5470display_name = "Stargate"
5471token = "t"
5472models = ["claude-sonnet-5"]
5473provider = "anthropic"
5474[[accounts]]
5475display_name = "Stargate"
5476token = "t"
5477models = ["claude-sonnet-5"]
5478provider = "anthropic"
5479"#,
5480 );
5481 let err = load_from_dir(dir.path()).expect_err("duplicate account should error");
5482 assert!(matches!(err, WorkspaceError::DuplicateAccount { name, .. } if name == "Stargate"));
5483 }
5484
5485 #[test]
5491 fn every_ignored_key_warns() {
5492 let cases = [
5493 (
5494 "[dictate] device",
5495 "\n[dictate]\ndevice = \"shure-id\"\n",
5496 "dictate_device_key_ignored",
5497 ),
5498 ("[tts] language", "\n[tts]\nlanguage = \"en\"\n", "tts_language_key_ignored"),
5499 (
5500 "a gateway key in an env layer",
5501 "\n[env]\nANTHROPIC_BASE_URL = \"https://proxy.example\"\n",
5502 "gateway_keys_dropped_from_env_layer",
5503 ),
5504 (
5505 "a [models] entry for a model nobody pins",
5506 "\n[models.\"nobody-pins-this\"]\nauto_compact_window = 100\n",
5507 "model_parameters_unpinned",
5508 ),
5509 ];
5510 for (label, stanza, event) in cases {
5511 let dir = tempdir().expect("tempdir");
5512 write_config(dir.path(), &format!("{}{stanza}", minimal_config()));
5513 let (loaded, logged) = load_capturing(dir.path());
5514 assert!(loaded.is_ok(), "{label} still loads");
5515 assert!(
5516 logged.warns(event),
5517 "{label} warns at WARN with {event}; saw {:?}",
5518 logged.names(),
5519 );
5520 }
5521 }
5522
5523 #[test]
5528 fn a_models_table_keys_on_the_spelling_a_project_pins() {
5529 let config_for = |table_key: &str| {
5530 format!(
5531 r#"
5532[[orgs]]
5533name = "Personal"
5534accounts = ["Stargate"]
5535
5536[[orgs.projects]]
5537name = "forge"
5538path = "~/Projects/forge"
5539model = "claude-opus-5"
5540
5541[[accounts]]
5542display_name = "Stargate"
5543token = "t"
5544models = ["claude-opus-5[1m]"]
5545provider = "anthropic"
5546
5547[accounts.model_aliases]
5548"claude-opus-5[1m]" = ["claude-opus-5"]
5549
5550[models."{table_key}"]
5551auto_compact_window = 983040
5552"#
5553 )
5554 };
5555
5556 let dir = tempdir().expect("tempdir");
5557 write_config(dir.path(), &config_for("claude-opus-5"));
5558 let (loaded, logged) = load_capturing(dir.path());
5559 let config = loaded.expect("the pinned spelling loads");
5560 assert_eq!(
5561 config
5562 .models
5563 .get("claude-opus-5")
5564 .and_then(|parameters| parameters.auto_compact_window),
5565 Some(983_040),
5566 "the entry the project pins is the one read",
5567 );
5568 assert!(
5569 !logged.warns("model_parameters_unpinned"),
5570 "and it warns nothing: {:?}",
5571 logged.names(),
5572 );
5573
5574 let dir = tempdir().expect("tempdir");
5575 write_config(dir.path(), &config_for("claude-opus-5[1m]"));
5576 let (loaded, logged) = load_capturing(dir.path());
5577 assert!(loaded.is_ok(), "the account-facing spelling still loads");
5578 assert!(
5579 logged.warns("model_parameters_unpinned"),
5580 "an entry no project pins warns even though an account declares it: {:?}",
5581 logged.names(),
5582 );
5583 }
5584
5585 #[test]
5588 fn a_mistyped_models_key_is_refused() {
5589 let dir = tempdir().expect("tempdir");
5590 write_config(
5591 dir.path(),
5592 r#"
5593[[orgs]]
5594name = "Personal"
5595accounts = ["Stargate"]
5596
5597[[orgs.projects]]
5598name = "forge"
5599path = "~/Projects/forge"
5600model = "claude-sonnet-5"
5601
5602[[accounts]]
5603display_name = "Stargate"
5604token = "t"
5605models = ["claude-sonnet-5"]
5606provider = "anthropic"
5607
5608[models."claude-sonnet-5"]
5609auto_compact_windows = 983040
5610"#,
5611 );
5612 let err = load_from_dir(dir.path()).expect_err("a mistyped key should be a parse failure");
5613 let message = err.to_string();
5614 assert!(
5615 message.contains("auto_compact_windows"),
5616 "the refusal names the key that was not recognized, got: {message}",
5617 );
5618 }
5619
5620 #[test]
5626 fn a_relative_document_root_is_refused_at_load_naming_key_and_entry() {
5627 for entry in ["Madness", "~user/Madness"] {
5628 let dir = tempdir().expect("tempdir");
5629 write_config(
5630 dir.path(),
5631 &format!(
5632 r#"
5633[[orgs]]
5634name = "Personal"
5635accounts = ["Stargate"]
5636
5637[[orgs.projects]]
5638name = "forge"
5639path = "~/Projects/forge"
5640document_roots = ["~/Madness", "{entry}"]
5641
5642[[accounts]]
5643display_name = "Stargate"
5644token = "t"
5645models = ["claude-sonnet-5"]
5646provider = "anthropic"
5647"#,
5648 ),
5649 );
5650
5651 let refusal = load_from_dir(dir.path()).expect_err("a relative entry is a load error");
5652 assert!(
5653 matches!(refusal, WorkspaceError::ProjectDocumentRootRelative { .. }),
5654 "{entry}: {refusal:?}"
5655 );
5656 let why = refusal.to_string();
5657 assert!(why.contains("document_roots"), "{entry}: names the key: {why}");
5658 assert!(why.contains(&format!("'{entry}'")), "{entry}: names the entry: {why}");
5659 assert!(why.contains("'forge'"), "{entry}: and the project it sits on: {why}");
5660 assert!(why.contains("start with ~/"), "{entry}: and the remedy it accepts: {why}");
5661 }
5662 }
5663
5664 #[test]
5667 fn document_roots_default_to_empty_and_expand_home() {
5668 let dir = tempdir().expect("tempdir");
5669 write_config(dir.path(), minimal_config());
5670 let config = load_from_dir(dir.path()).expect("loads");
5671 assert!(named(&config, "forge").document_roots.is_empty(), "absent key, no roots");
5672
5673 let dir = tempdir().expect("tempdir");
5674 write_config(
5675 dir.path(),
5676 r#"
5677[[orgs]]
5678name = "Personal"
5679accounts = ["Stargate"]
5680
5681[[orgs.projects]]
5682name = "forge"
5683path = "~/Projects/forge"
5684document_roots = ["~/Madness", "/opt/vault", "~"]
5685
5686[[accounts]]
5687display_name = "Stargate"
5688token = "t"
5689models = ["claude-sonnet-5"]
5690provider = "anthropic"
5691"#,
5692 );
5693 let config = load_from_dir(dir.path()).expect("loads");
5694 let home = dirs::home_dir().expect("home");
5695 assert_eq!(
5696 named(&config, "forge").document_roots,
5697 vec![home.join("Madness"), PathBuf::from("/opt/vault"), home],
5698 "entries expand ~ and ~/, and keep absolutes as themselves",
5699 );
5700 }
5701}