Skip to main content

forge_workspace/
config.rs

1//! `forge.toml` schema + loader.
2//!
3//! **Org model.** Projects are grouped under `[[orgs]]` (Stargate,
4//! Gateway, Personal, etc.). Each org carries the `accounts = [...]`
5//! pin shared by all its projects, replacing the per-project pin
6//! that lived here before. Projects within an org keep a flat list
7//! via `[[orgs.projects]]`. A launch with no project named opens every
8//! project whose seat the store records as open, resuming the session
9//! forge stored for each unless `--new` is passed; the alphabetically
10//! first project is the fallback target when no project is named and no
11//! picker chooses one.
12//!
13//! **Selection policy.** The gateway's declared-model walk: over the
14//! org's `accounts` primaries and `fallback_accounts`, the first
15//! account that declares the project's `model`, preferring ready over
16//! saturated and keeping bailed last (saturated then leads to a
17//! cooling filter). Utilization is never compared between accounts; it
18//! collapses to one boolean per account. Every spawn in an org takes
19//! the same walk, so there is no per-session spread.
20
21use 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/// `[huggingface]`: the token a gated Hub repository needs. The speaking
37/// model's own repository is open, so an absent token changes nothing.
38#[derive(Clone, Default, Deserialize, PartialEq)]
39#[serde(deny_unknown_fields)]
40pub struct HuggingfaceSettings {
41    #[serde(default)]
42    pub token: Option<String>,
43}
44
45/// Hand-written because the token is a credential.
46impl 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/// Optional `[vault]` section: the Vaultwarden account the vault family
55/// talks to. Both `url` and `email` are required when the section is
56/// present; there is no built-in default, because `forge.toml` is the one
57/// source of truth for the account.
58#[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    /// Minutes without a vault call after which the session locks itself.
66    /// Absent means no idle lock, which is what forge did before this key.
67    #[serde(default)]
68    pub idle_minutes: Option<u64>,
69}
70
71/// The validated `[vault]` section.
72#[derive(Clone, PartialEq, Eq)]
73pub(crate) struct VaultConfig {
74    pub url: String,
75    pub email: String,
76    /// `None` when `idle_minutes` is unset: no idle lock.
77    pub idle_minutes: Option<u64>,
78}
79
80/// Hand-written because the email is the account's identity and the salt the
81/// master key derives with.
82impl 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    /// Validate the section, naming the first missing key by name so a
94    /// half-written table fails the load rather than a tool call.
95    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        // Zero would read as "lock instantly", which no one means; the way to
108        // have no idle lock is to leave the key out.
109        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/// The whole `forge.toml`. Unknown keys are rejected so a mistyped
117/// section name fails the load instead of being ignored.
118#[derive(Deserialize)]
119#[serde(deny_unknown_fields)]
120struct ForgeToml {
121    #[serde(default)]
122    orgs: Vec<OrgEntry>,
123    #[serde(default)]
124    accounts: Vec<AccountEntry>,
125    /// Optional `[models."<name>"]` tables - parameters a model name
126    /// carries to every spawn. Absent means no model parameters are
127    /// stamped and the CLI's own resolution stands.
128    #[serde(default)]
129    models: HashMap<String, ModelSettings>,
130    /// Optional `[handoff]` section - when forge replaces a session with a
131    /// fresh one that continues from a brief. Absent means the defaults,
132    /// which is on at 85% with a compaction backstop of 1.
133    #[serde(default)]
134    handoff: HandoffSettings,
135    /// Optional `[dictate]` section - local dictation. Absent section
136    /// -> disabled, which is what keeps a 3 GB model download opt-in.
137    #[serde(default)]
138    dictate: crate::dictate::DictateSettings,
139    /// Optional `[tts]` section - speech out. Absent section -> disabled,
140    /// which keeps the model install opt-in.
141    #[serde(default)]
142    tts: crate::tts::TtsSettings,
143    /// Optional `[huggingface]` section - the token a gated Hub repository
144    /// needs. Absent section leaves every Hub read untokened.
145    #[serde(default)]
146    huggingface: HuggingfaceSettings,
147    /// Optional `[gotify]` section - the inbound-notification server
148    /// connection. Absent section → `None` → the Gotify subsystem
149    /// stays dormant.
150    #[serde(default)]
151    gotify: Option<GotifyConfig>,
152    /// Optional `[systemone]` section - the decision-model endpoint the
153    /// session tools ask. Absent or `enabled = false` → `None` → the
154    /// tools are never injected.
155    #[serde(default)]
156    systemone: Option<forge_system_one::SystemOneSection>,
157    /// Optional `[vault]` section - the Vaultwarden account the vault
158    /// family talks to. Absent -> the family is not injected.
159    #[serde(default)]
160    vault: Option<VaultSection>,
161    /// Optional `[[slack]]` sections - one entry per workspace. Absent or
162    /// empty → the connector stays dormant.
163    #[serde(default)]
164    slack: Vec<SlackConfig>,
165    /// Optional `[plugins]` section - opt-in plugin auto-update.
166    /// Absent section → all defaults, which leaves auto-update off.
167    #[serde(default)]
168    plugins: PluginSettings,
169    /// Optional `[gateway]` section - the inference listener's port.
170    #[serde(default)]
171    gateway: Option<GatewaySettings>,
172    /// Optional `[server]` section - the socket's listener. Absent
173    /// section means its defaults, which is on and on loopback.
174    #[serde(default)]
175    server: ServerConfig,
176    /// Optional `[client]` section - the sets a client draws with. Absent
177    /// section means every name unset, which is the built-in.
178    #[serde(default)]
179    client: ClientConfig,
180    /// Optional `[browser]` section - how the server's browser hosts encode
181    /// and when an idle profile closes. Absent section keeps the defaults.
182    #[serde(default)]
183    browser: BrowserSettings,
184    /// Optional `[terminal]` section - the shell a seat's terminal runs, how
185    /// much scrollback it keeps, and when an idle one closes. Absent section
186    /// keeps the defaults.
187    #[serde(default)]
188    terminal: TerminalSettings,
189    /// Optional `[documents]` section - the ceiling on one handed
190    /// document. Absent section keeps the 10 MiB default.
191    #[serde(default)]
192    documents: DocumentsSettings,
193    /// Optional `[usage]` section - the background poller's refresh
194    /// interval. Absent section keeps the 60 s default.
195    #[serde(default)]
196    usage: UsageSettings,
197    /// Optional `[sessions]` section - whether a dead durable seat comes
198    /// back. Absent section keeps the defaults, which heal it.
199    #[serde(default)]
200    sessions: SessionsSection,
201    /// Optional top-level `[env]` table - the BASE every session
202    /// starts from, overridden per key by `[accounts.env]` and then by
203    /// the project's env. Merged into `LoadedAccount.env` at
204    /// load; the project layer is applied at spawn. Absent -> empty.
205    #[serde(default)]
206    env: HashMap<String, String>,
207}
208
209/// Hand-written so the global env's values stay out of it; the sections it
210/// holds redact themselves.
211impl 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
241/// The ceiling a handed document has to fit under when the
242/// `[documents] max_size_mib` key is absent, in MiB.
243pub const DEFAULT_DOCUMENT_MAX_MIB: u64 = 10;
244
245/// The highest `[documents] max_size_mib` forge accepts, in MiB. A document
246/// crosses the socket base64-encoded, a third larger, and the desktop
247/// client's frame cap is 256 MiB (`client/src-tauri`'s `MAX_FRAME_BYTES`):
248/// 190 MiB raw lands at ~253.3 MiB, inside the cap with room for the
249/// answer's other fields.
250pub const DOCUMENT_MAX_MIB_LIMIT: u64 = 190;
251
252/// The interval the background usage poller refreshes on when the
253/// `[usage] poll_seconds` key is absent, in seconds. 60 s stays clear of
254/// the OAuth usage endpoint's 429 throttle under multi-instance polling
255/// while bounding how stale the account-usage cache can be.
256pub const DEFAULT_USAGE_POLL_SECONDS: u64 = 60;
257
258/// Whether a dead durable seat comes back when `[sessions] heal` is absent.
259pub const DEFAULT_HEAL: bool = true;
260
261/// The restart count when `[sessions] heal_attempts` is absent: three
262/// restarts, which caps what a deterministic fault can burn.
263pub const DEFAULT_HEAL_ATTEMPTS: usize = 3;
264
265/// The waits before each restart when `[sessions] heal_backoff` is absent,
266/// in seconds: 5 s, 30 s, then 2 min.
267pub const DEFAULT_HEAL_BACKOFF_SECONDS: [u64; 3] = [5, 30, 120];
268
269/// How long a restarted seat must stay up before its death count resets
270/// when `[sessions] heal_stable` is absent, in seconds.
271pub const DEFAULT_HEAL_STABLE_SECONDS: u64 = 600;
272
273/// One `[usage]` table. Unknown fields are rejected so a mistyped
274/// key fails the load instead of being ignored.
275#[derive(Debug, Default, Deserialize)]
276#[serde(deny_unknown_fields)]
277struct UsageSettings {
278    /// How often the background poller refreshes account usage, in
279    /// seconds. The default is [`DEFAULT_USAGE_POLL_SECONDS`], and the
280    /// key exists for the machine that wants a different one.
281    #[serde(default)]
282    poll_seconds: Option<u64>,
283}
284
285impl UsageSettings {
286    /// The resolved interval. 0 is refused rather than obeyed: a
287    /// zero-second interval would probe continuously.
288    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/// The validated `[sessions]` table: whether a dead durable seat comes
298/// back, how many restarts it gets, the wait before each, and how long a
299/// restarted seat must stay up before its count resets.
300#[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/// One `[sessions]` table. Unknown fields are rejected so a mistyped key
324/// fails the load instead of being ignored.
325#[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    /// Resolve the section, refusing the counts and durations that would
340    /// silently mean something other than they say.
341    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
393/// A `[sessions]` duration as the file writes it: digits and one unit,
394/// `ms`, `s`, `m` or `h`. A bare number is refused rather than guessed.
395fn 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
408/// The file's spelling of a resolved `[sessions]` duration, for the
409/// settings page: the largest unit that divides it exactly.
410pub(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/// The `[browser]` table: how the server's browser hosts encode, and when an
424/// idle profile closes. Unknown fields are rejected so a mistyped key fails
425/// the load instead of being ignored.
426#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
427#[serde(default, deny_unknown_fields)]
428pub struct BrowserSettings {
429    /// Frames encoded per second; faster paints are coalesced. 1 to 120.
430    pub fps: u16,
431    /// Lossy WebP quality, 0 to 100. A key frame is lossless regardless.
432    pub quality: u8,
433    /// Minutes a profile no viewer has used may sit before it closes.
434    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/// The `[terminal]` table: the shell a seat's terminal runs, how much
444/// scrollback the model keeps, and when an idle terminal closes. Unknown
445/// fields are rejected so a mistyped key fails the load instead of being
446/// ignored.
447#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
448#[serde(default, deny_unknown_fields)]
449pub struct TerminalSettings {
450    /// The shell to run. Empty means `$SHELL`, falling back to `/bin/zsh`
451    /// on macOS.
452    pub shell: String,
453    /// The scrollback the model keeps, in lines. 0 keeps none.
454    pub scrollback_lines: usize,
455    /// Minutes an untouched, unattached terminal may sit before it closes.
456    /// 0 is off.
457    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
466/// The shell a terminal runs when `[terminal] shell` names none: the
467/// person's own `$SHELL`, or the platform's login shell.
468fn 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/// One `[documents]` table. Unknown fields are rejected so a mistyped
475/// key fails the load instead of being ignored.
476#[derive(Debug, Default, Deserialize)]
477#[serde(deny_unknown_fields)]
478struct DocumentsSettings {
479    /// The ceiling on one handed document, in MiB. The default is
480    /// [`DEFAULT_DOCUMENT_MAX_MIB`], and the key exists for the machine
481    /// that wants a different one.
482    #[serde(default)]
483    max_size_mib: Option<u64>,
484}
485
486impl DocumentsSettings {
487    /// The resolved ceiling in bytes. 0 and a magnitude a byte count
488    /// cannot hold are refused rather than obeyed, and so is a value past
489    /// [`DOCUMENT_MAX_MIB_LIMIT`]: each would mean something other than a
490    /// ceiling a client can receive.
491    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/// One `[gateway]` table. Unknown fields are rejected so a mistyped
508/// key fails the load instead of being ignored.
509#[derive(Debug, Deserialize)]
510#[serde(deny_unknown_fields)]
511struct GatewaySettings {
512    /// The inference listener's port. Fixed by default.
513    #[serde(default)]
514    port: Option<u16>,
515    /// How many consecutive 429s from one account fire a rotation.
516    #[serde(default)]
517    streak_count: Option<u32>,
518    /// The window the 429 streak is counted over, in seconds.
519    #[serde(default)]
520    streak_window_secs: Option<u64>,
521    /// The cooldown when no reset time is known, in seconds.
522    #[serde(default)]
523    no_reset_cooldown_secs: Option<u64>,
524}
525
526impl GatewaySettings {
527    /// The resolved port, refusing 0: the port is fixed by design, and
528    /// a 0 here would ask the OS for an ephemeral port no child was
529    /// ever told about.
530    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    /// The resolved rotation numbers, refusing 0 on any of them: each
539    /// 0 would silently disable the mechanism it configures, which
540    /// reads as a bug rather than a choice. A magnitude above u32
541    /// seconds is refused the same way - silently mapping it to the
542    /// default is the substitution this refusal exists to prevent.
543    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    /// The resolved mode for the project. Absent is `auto`, not "no
603    /// override": under account rotation a session can start on any
604    /// account in its org's pool, so the project is the stable scope
605    /// and a forge default is what keeps its sessions consistent.
606    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/// One `[[orgs]]` entry. Unknown keys are rejected: a misspelled
625/// `fallback_accounts` parses clean as "no fallbacks" otherwise, which
626/// is a silent behaviour change from a typo.
627#[derive(Debug, Deserialize)]
628#[serde(deny_unknown_fields)]
629struct OrgEntry {
630    name: String,
631    /// Account `display_name`s every project in this org is allowed
632    /// to spawn under. Required; cross-validated against `[[accounts]]`.
633    accounts: Vec<String>,
634    /// Fallback `display_name`s used when every pinned account is
635    /// unavailable. Absent -> empty. Validated against `[[accounts]]`
636    /// exactly like `accounts`.
637    #[serde(default)]
638    fallback_accounts: Vec<String>,
639    /// The org's browser profile name; `<org>/<name>`. Absent is `default`.
640    #[serde(default)]
641    browser_profile: Option<String>,
642    /// The Slack workspace every project in the org inherits. Absent
643    /// leaves each project to bind its own or none.
644    #[serde(default)]
645    slack: Option<SlackBinding>,
646    #[serde(default)]
647    projects: Vec<ProjectEntry>,
648}
649
650/// `[orgs.slack]` or `[orgs.projects.slack]`: the one `[[slack]]` label a
651/// scope's sessions act in.
652#[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    /// The project's model: fills the CLI's model slots at spawn
664    /// (`ANTHROPIC_DEFAULT_*_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`,
665    /// `CLAUDE_CODE_SUBAGENT_MODEL`). Required to be declared by at
666    /// least one account in the org.
667    #[serde(default)]
668    model: Option<String>,
669    /// CLI permission mode stamped onto every session this project
670    /// spawns. Absent resolves to `auto`.
671    #[serde(default)]
672    permission_mode: Option<String>,
673    /// Cap on this project's concurrently live dynamic workers.
674    /// Absent keeps the default.
675    #[serde(default)]
676    max_workers: Option<usize>,
677    /// Per-project environment entries, layered over the account's
678    /// env at spawn.
679    #[serde(default)]
680    env: HashMap<String, String>,
681    /// Path to a `KEY=value` file whose entries join this project's
682    /// env, so a secret can live outside forge.toml. Read once at
683    /// load, like every other value here, so rotating it needs a
684    /// forge restart.
685    #[serde(default)]
686    env_file: Option<String>,
687    /// Extra roots the document read accepts beyond the session's own
688    /// tree, so a project's documents can live outside its repo (a
689    /// vault, a shared spec directory). Each entry is absolute or
690    /// starts with `~`; a relative one is a load error. Absent ->
691    /// empty.
692    #[serde(default)]
693    document_roots: Vec<String>,
694    /// This project's handoff trigger, layered over the top-level table.
695    /// Every key is optional, so a project sets the one it disagrees on.
696    #[serde(default)]
697    handoff: Option<HandoffSettings>,
698    /// This project's browser profile name; `<org>/<name>`. Absent keeps
699    /// the org's.
700    #[serde(default)]
701    browser_profile: Option<String>,
702    /// This project's Slack workspace, overriding the org's. Absent keeps
703    /// the org's binding.
704    #[serde(default)]
705    slack: Option<SlackBinding>,
706}
707
708/// Hand-written because a project's env may carry secrets, which the
709/// derived output would print.
710impl 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/// Unknown fields are rejected so a near-miss key (`providers`) fails
731/// loudly instead of loading and leaving the account probing the wrong
732/// endpoint until preflight hangs on it.
733#[derive(Deserialize)]
734#[serde(deny_unknown_fields)]
735struct AccountEntry {
736    display_name: String,
737    /// Which backend this account talks to. Required: an account that
738    /// does not say probes the wrong endpoint and bails at preflight,
739    /// so silence is the dangerous answer. Held as `Option` only so the
740    /// absent case can name the account; `None` is a load error.
741    provider: Option<forge_primitives::account::Provider>,
742    /// The flat base URL. Required for base-url providers; an
743    /// Anthropic account may omit it (the gateway constant is its
744    /// upstream).
745    #[serde(default)]
746    base_url: Option<String>,
747    /// The credential the gateway forwards, mapped onto the right
748    /// variable per provider. Required.
749    #[serde(default)]
750    token: Option<String>,
751    /// The canonical model names the account serves. Required
752    /// non-empty.
753    #[serde(default)]
754    models: Vec<String>,
755    /// Canonical name -> upstream slug, only where the spellings
756    /// differ.
757    #[serde(default)]
758    model_slugs: HashMap<String, String>,
759    /// Canonical name -> the other names a request may arrive under for
760    /// that model.
761    #[serde(default)]
762    model_aliases: HashMap<String, Vec<String>>,
763    /// Provider-behaviour extras only - timeouts, context caps,
764    /// fallback switches. Base-url and credential keys are rejected:
765    /// they are the flat keys' job.
766    #[serde(default)]
767    env: HashMap<String, String>,
768}
769
770/// Hand-written because the flat token and the env map are credentials.
771impl 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
788// The loaded account shape lives in forge-primitives: the gateway
789// builds its account state from it, so it crosses a crate boundary.
790// Re-exported here so `crate::config::LoadedAccount` keeps resolving.
791pub(crate) use forge_primitives::account::LoadedAccount;
792
793/// The `[plugins]` section. Unknown fields are rejected so a mistyped
794/// key cannot silently leave auto-update doing nothing.
795#[derive(Debug, Clone, Deserialize, Default)]
796#[serde(deny_unknown_fields)]
797pub struct PluginSettings {
798    /// Update every installed plugin once at forge boot - the switch
799    /// alone governs, there is no per-marketplace or per-plugin opt-out.
800    /// Off by default: an auto-applied plugin update can break a
801    /// load-bearing session mid-day, so forge only ever moves a plugin
802    /// the user opted in.
803    #[serde(default)]
804    pub auto_update: bool,
805}
806
807/// Cap on a project's concurrently live dynamic workers when its
808/// `max_workers` override is absent.
809pub(crate) const DEFAULT_MAX_WORKERS_PER_PROJECT: usize = 2;
810
811/// The `[models."<name>"]` table: the parameters a model name carries to
812/// every spawn, keyed by the canonical name forge.toml already pins
813/// projects to. Model-shaped values live here rather than in code, so
814/// changing a model is a config edit. Unknown fields are rejected so a
815/// mistyped key cannot silently leave a parameter unset.
816#[derive(Debug, Clone, Deserialize, Default)]
817#[serde(deny_unknown_fields)]
818pub struct ModelSettings {
819    /// The window, in tokens, the CLI's auto-compaction triggers against,
820    /// stamped onto the child as `CLAUDE_CODE_AUTO_COMPACT_WINDOW`. It is
821    /// set below the model's context so the compact request has room to
822    /// fit - compaction fires near a window's top, and the request itself
823    /// needs headroom beyond that. Absent leaves the CLI's own resolution
824    /// in place.
825    #[serde(default)]
826    pub auto_compact_window: Option<i64>,
827}
828
829/// The share of the window a session hands off at when the key is absent.
830pub const DEFAULT_HANDOFF_AT_PERCENT: u8 = 85;
831
832/// The compaction count the backstop hands off at when the key is absent.
833/// One is the first compaction: the fill trigger should have fired before it,
834/// and a session that reaches two is the alarm the record exists for.
835pub const DEFAULT_HANDOFF_AFTER_COMPACTIONS: u32 = 1;
836
837/// The `[handoff]` table, top-level or per project. Every key is optional so
838/// a project's table can set the one it disagrees on and inherit the rest.
839#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
840#[serde(deny_unknown_fields)]
841pub struct HandoffSettings {
842    /// `false` turns the automatic trigger off and leaves `/handoff` alone.
843    #[serde(default)]
844    pub enabled: Option<bool>,
845    /// The share of the CLI's effective window the fill trigger fires at.
846    /// Widened past `u8` so a value above 255 gets this crate's own refusal
847    /// rather than serde's, and validation bounds what survives to 1..=100.
848    #[serde(default)]
849    pub at_percent: Option<u32>,
850    /// The compaction count the backstop fires at.
851    #[serde(default)]
852    pub after_compactions: Option<u32>,
853}
854
855/// The trigger a session reads, with the project's table over the top-level
856/// one and the defaults under both.
857#[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
896/// Refuse a trigger that can never fire or fires on every turn. `scope` names
897/// the table the value came from, so the error says which one to edit.
898fn 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    /// The top-level `[env]` layer, after the gateway keys were dropped at
924    /// load. Kept whole so the settings page can draw the layer itself
925    /// rather than re-reading one account's merged env.
926    pub global_env: HashMap<String, String>,
927    /// Each org's resolved browser profile name, keyed by org name: what
928    /// the boot's profile resolver reads to place an org's browsers.
929    pub org_browser_profiles: HashMap<String, String>,
930    /// Each org's own `[orgs.slack]` binding, keyed by org name. A project
931    /// may override it; the resolved per-project binding is on the project.
932    pub org_slack: HashMap<String, String>,
933    /// Index into `projects` for the fallback target: the
934    /// alphabetically-first project. Read only when no project is named and
935    /// no picker selects one; a named project and the picker both override
936    /// it.
937    pub default_index: usize,
938    pub accounts: Vec<LoadedAccount>,
939    /// `[models."<name>"]` tables, keyed by the spelling a project's
940    /// `model` pins. Absent tables mean a spawn stamps no model
941    /// parameters.
942    pub models: HashMap<String, ModelSettings>,
943    /// `[handoff]` section knobs, unresolved: a project's own table layers
944    /// over these, and the defaults sit under both.
945    pub handoff: HandoffSettings,
946    /// `[dictate]` section knobs. Absent section means dictation is
947    /// off and preflight skips it entirely.
948    pub dictate: crate::dictate::DictateSettings,
949    /// `[tts]` section knobs. Absent section means the speaking role is
950    /// off and nothing synthesises.
951    pub tts: crate::tts::TtsSettings,
952    /// `[huggingface]` token, or the default when the section is absent.
953    pub huggingface: HuggingfaceSettings,
954    /// `[gotify]` server connection, or `None` when the section is
955    /// absent (Gotify disabled).
956    pub gotify: Option<GotifyConfig>,
957    /// `[systemone]` decision-model endpoint, or `None` when the
958    /// section is absent or disabled (the tools are not injected).
959    pub systemone: Option<forge_system_one::SystemOneConfig>,
960    /// `[vault]` account, or `None` when the section is absent (the family
961    /// is not injected).
962    pub vault: Option<VaultConfig>,
963    /// `[[slack]]` workspaces. An absent or empty list leaves the
964    /// connector dormant, the way an absent `[gotify]` does.
965    pub slack: Vec<SlackConfig>,
966    /// `[plugins]` section knobs. Absent section means auto-update is
967    /// off.
968    pub plugins: PluginSettings,
969    /// The port the gateway's inference listener binds. Absent
970    /// `[gateway]` section keeps the default.
971    pub gateway_port: u16,
972    /// The gateway's rotation numbers. Absent keys keep the defaults.
973    pub gateway_rotation: forge_gateway::rotation::RotationNumbers,
974    /// `[server]` section knobs. Absent section means the listener's
975    /// defaults: on, on loopback.
976    pub server: ServerConfig,
977    /// `[client]` section knobs. Absent section means every name unset,
978    /// which is the built-in rather than a pin.
979    pub client: ClientConfig,
980    /// `[browser]` section knobs, resolved: the encoding rate and quality,
981    /// and the idle release's bound. Absent section keeps the defaults.
982    pub browser: BrowserSettings,
983    /// `[terminal]` section knobs, resolved: the shell to run, the
984    /// scrollback bound and the idle clock. Absent section keeps the
985    /// defaults.
986    pub terminal: TerminalSettings,
987    /// The ceiling on one handed document, in bytes: `[documents]
988    /// max_size_mib` resolved, 10 MiB when the key is absent. Read by the
989    /// document read, which refuses over it.
990    pub document_max_bytes: u64,
991    /// The background usage poller's interval: `[usage] poll_seconds`
992    /// resolved, 60 seconds when the key is absent. Bounds how stale the
993    /// account-usage cache is.
994    pub usage_poll_interval: Duration,
995    /// `[sessions]` section knobs, resolved. Absent section keeps the
996    /// defaults, which heal a dead durable seat.
997    pub sessions: SessionsConfig,
998    /// The fingerprint of the raw file text this load read, which the
999    /// settings document carries and a save compares against.
1000    pub raw_fingerprint: String,
1001}
1002
1003/// Hand-written so a field added later cannot print through it until it
1004/// is deliberately listed. Its credential carriers - the accounts' and
1005/// projects' env stamps, gotify's token, systemone's key - are redacted
1006/// by the structs they live on.
1007impl 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/// What one `forge.toml` reload changed, in the terms the boot's log line
1041/// reads: the tables whose new values are live from their next use, and the
1042/// keys whose values are stored but need a restart.
1043#[derive(Debug, Clone, Default, PartialEq, Eq)]
1044pub struct ConfigReload {
1045    /// The concrete key paths whose new value applies without a restart.
1046    pub changed: Vec<String>,
1047    /// The concrete key paths whose value is stored but needs one.
1048    pub restart_only: Vec<String>,
1049}
1050
1051impl ConfigReload {
1052    /// Whether any reported key sits under `prefix`, for the consumers that
1053    /// act on a whole table (`accounts.`, `gateway.`).
1054    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    /// The reload summary between this load and `next`, read from the one
1061    /// class catalogue the settings page draws its chips from.
1062    pub(crate) fn diff(&self, next: &Self) -> ConfigReload {
1063        crate::settings::diff(self, next)
1064    }
1065}
1066
1067/// The label a scope's `slack` binding resolves to, or a load error naming
1068/// the label asked for and the ones that exist: a binding to a workspace
1069/// forge does not have would leave every Slack tool dead at runtime.
1070fn 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
1093/// The port the gateway's listener binds when `[gateway] port` is
1094/// absent. Fixed, not OS-assigned: every session's base URL names it,
1095/// so a drift would point children at an address nothing serves.
1096pub const DEFAULT_GATEWAY_PORT: u16 = 8787;
1097
1098#[derive(Clone)]
1099pub(crate) struct LoadedProject {
1100    pub name: String,
1101    pub path: PathBuf,
1102    /// Original path string from `forge.toml`, preserved for display
1103    /// (e.g. `~/Projects/forge` with `~` un-expanded). Use `path` for
1104    /// filesystem access; this for human-readable output.
1105    pub display_path: String,
1106    /// Name of the org this project belongs to (matches
1107    /// `LoadedOrg.name`). The spawn resolves the org's pin and walk
1108    /// order through this back-reference.
1109    pub org: String,
1110    /// Cached pinned account list from the project's org. Duplicated
1111    /// here so callers don't need to walk the org list on every
1112    /// resolution.
1113    pub accounts: Vec<String>,
1114    /// Cached fallback list from the project's org, alongside
1115    /// `accounts`. Absent key -> empty.
1116    pub fallback_accounts: Vec<String>,
1117    /// The project's model: fills the CLI's model slots at spawn
1118    /// (`ANTHROPIC_DEFAULT_*_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`,
1119    /// `CLAUDE_CODE_SUBAGENT_MODEL`) and is the model the walk matches
1120    /// accounts on. A project that declares none cannot spawn.
1121    pub model: Option<String>,
1122    /// Per-project environment from the entry's env table, layered
1123    /// over the account's env at spawn. An `ANTHROPIC_BASE_URL` or
1124    /// `ANTHROPIC_AUTH_TOKEN` here desyncs forge's own accounting -
1125    /// the usage probe and the selection walk both read the ACCOUNT
1126    /// map, so they measure a different endpoint.
1127    pub env: HashMap<String, String>,
1128    /// The `env_file` path the entry declared, kept for the settings page;
1129    /// its entries are already folded into `env`.
1130    pub env_file: Option<String>,
1131    /// Cap on this project's live dynamic workers; `None` keeps the
1132    /// default. See `ProjectEntry::max_workers`.
1133    pub max_workers: Option<usize>,
1134    /// CLI permission mode stamped onto every session this project
1135    /// spawns. Resolved once at load; absent key resolves to `auto`.
1136    pub permission_mode: PermissionMode,
1137    /// The extra roots the document read accepts beyond a session's own
1138    /// tree: the entry's `document_roots` with `~` expanded. Empty for a
1139    /// project that declares none.
1140    pub document_roots: Vec<PathBuf>,
1141    /// This project's handoff table, unresolved; `None` inherits the
1142    /// top-level one entirely.
1143    pub handoff: Option<HandoffSettings>,
1144    /// The browser profile this project's sessions drive: the project's
1145    /// override, else the org's, else `<org>/default`.
1146    pub browser_profile: ProfileKey,
1147    /// The `[[slack]]` label this project's sessions act in: the
1148    /// project's binding, else the org's. `None` leaves the Slack tools
1149    /// refusing with the key to add.
1150    pub slack_workspace: Option<String>,
1151}
1152
1153/// Hand-written because a project's env may carry secrets (`env_file`
1154/// exists so one can live outside forge.toml), which the derived output
1155/// would print.
1156impl 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
1180/// Complete `[env]` < `[accounts.env]` < the project's env,
1181/// narrowest winning per key, over the already-merged `account_env`.
1182/// Applied here rather than at load because one account serves many
1183/// projects, so merging earlier would leak a project's keys into every
1184/// other project on that account.
1185pub(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
1194/// A seat's children's environment: the server's own first, then the
1195/// account and project tables over it.
1196///
1197/// **The `claude` child gets this by inheritance; a terminal's shell does
1198/// not**, because its spawner hands it a map instead of inheriting and
1199/// overriding. Without the server's own set the shell has no `HOME` and
1200/// the shell integration reads the person's rc from nowhere.
1201pub(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
1210/// Sorted key NAMES a project contributes - never values; these tables
1211/// hold tokens.
1212pub(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    /// Empty `LoadedConfig` for the `testing` feature's
1224    /// `Workspace::testing_stub`. Production code paths that need a
1225    /// project (e.g. `default_project`) will panic when called on
1226    /// this value - tests that only need `domain_handles` access
1227    /// never reach those paths.
1228    #[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
1261/// The subdirectory holding every file forge itself owns (config,
1262/// state, cron, lock), kept apart from claude's own top-level
1263/// config-dir files. Pure path join; call [`ensure_forge_data_dir`]
1264/// when the directory has to exist before writing into it.
1265pub(crate) fn forge_data_dir(config_dir: &Path) -> PathBuf {
1266    config_dir.join("forge")
1267}
1268
1269/// [`forge_data_dir`] with a `create_dir_all`, returning the path.
1270pub(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
1276/// Read `forge/forge.toml`. Returns the path read plus its raw
1277/// contents.
1278fn 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
1289/// The `[server]` section, refusing the two ports that cannot work: 0, which
1290/// is OS-assigned and so unguessable, and the gateway's own port, where the
1291/// listener that binds second loses at boot.
1292///
1293/// A disabled listener is not validated at all. Nothing will bind that port,
1294/// so a stale one left in a hand-authored file is not worth refusing the
1295/// boot over.
1296fn 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
1316/// The `[client]` section: an unset name is not an error and not a
1317/// fallback, it means the built-in. A set one has to be a name forge ships,
1318/// because a key that is quietly ignored reads as the key not working.
1319fn 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
1338/// Where a parse failure sits in the document, from the byte span the error
1339/// carries: ` at line N, column M in <key>`, or empty when it names no span -
1340/// no position rather than a confident line 1.
1341///
1342/// The key is the text left of that line's `=` when that text is shaped
1343/// like a bare key (serde's own words do not name it for a value error, and
1344/// the snippet that used to is gone). A line with no `=` - a malformed
1345/// array element, a bare value - names nothing rather than quoting whatever
1346/// sits there as a key: the text right of the `=` is the value, and it
1347/// never prints.
1348fn 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
1365/// A TOML bare-key character (`a-z A-Z 0-9 _ - .`).
1366fn is_bare_key_char(ch: char) -> bool {
1367    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.'
1368}
1369
1370/// The error's own words with any double-quoted run scrubbed: serde renders
1371/// a mismatched value as `invalid type: string "…"`, and a value in this
1372/// file can be a token (#2267). A key is backtick-quoted and stays legible.
1373fn 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        // Drop the quoted run; an unterminated quote takes the tail with it
1384        // rather than printing a value whose end nobody found.
1385        match after.split_once('"') {
1386            Some((_, tail)) => rest = tail,
1387            None => return out,
1388        }
1389    }
1390}
1391
1392/// Load + validate `forge.toml`. Returns the parsed orgs + projects
1393/// with `~` expanded.
1394pub(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
1399/// Load + validate raw `forge.toml` text, with the path its errors name. The
1400/// settings save validates a candidate through here before it replaces the
1401/// file, so the file a save writes is one a boot would accept.
1402pub(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    // The boot's SlackWorkspaces build runs these checks; a reload goes
1410    // through the same loader, so it must refuse the same file a restart
1411    // would.
1412    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    // Global `[env]` is the BASE each account's effective env starts
1442    // from; the account's own `[accounts.env]` extends it, so account
1443    // keys override global keys.
1444    let mut global_env = parsed.env;
1445
1446    // Validate accounts first - orgs cross-reference them.
1447    let mut seen_account_names: std::collections::HashSet<String> =
1448        std::collections::HashSet::new();
1449    // Collected rather than reported one at a time: a first run after
1450    // the key became required trips every account at once, and naming
1451    // one per boot is that many edit-and-restart cycles.
1452    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        // The four gateway keys are the flat keys' and the stamp's
1474        // alone. They are dropped from every env layer here, blank or
1475        // not, before the layers merge: the stamp covers the child's
1476        // env only, while the pool keeps this one, and the forward leg
1477        // reads the upstream and the credential out of what the pool
1478        // holds. A base URL left in would send real traffic, with the
1479        // real credential attached, to an endpoint the config never
1480        // named as an account.
1481        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        // The flat credential: mapped onto the provider's own variable
1509        // below, which is what the probe, the gateway forward and the
1510        // child stamp all read.
1511        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        // A base-url provider probes `{base_url}/...`, so an absent key
1545        // would leave the probe pointed at Anthropic's host with the
1546        // wrong bearer. Refuse at load rather than at preflight.
1547        if provider.uses_base_url() && base_url.is_none() {
1548            return Err(WorkspaceError::AccountProviderNeedsBaseUrl {
1549                path,
1550                name: entry.display_name,
1551            });
1552        }
1553        // The key probe is `{base}/v1/key`, so a base that is the bare
1554        // host resolves to `openrouter.ai/v1/key` - which answers 200
1555        // with a marketing page. 200 is the one status the probe reads
1556        // as success, so it would reach the decode arm, retry to the
1557        // iteration cap and bail the account, stopping forge from
1558        // starting. Refuse the base here, where the message can say so.
1559        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        // Legal but self-inconsistent: the base is still stamped on the
1568        // spawned session, so chat goes to that endpoint while usage
1569        // probes the official API with the setup token.
1570        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        // Map the flat keys onto the provider's variables - what the
1579        // probe, the gateway forward and the child stamp all read.
1580        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    // Validate orgs + build the flat project list.
1597    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                    // A bare `~` is home, the same intent as `~/`; anything
1660                    // else that does not come out absolute is refused.
1661                    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            // The project model must be served by at least one account
1718            // the org can reach: a typo would boot clean, stamp all the
1719            // CLI slots, and then 503 every session's first request.
1720            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    // Default project resolution (used by `forge` without argv when
1748    // the launchpad picker isn't available - fixture tests, smoke
1749    // tests, etc.; the production path lands on the launchpad
1750    // picker first): the alphabetically-first project.
1751    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    // **A [browser] value outside its range is refused, not clamped**: a
1770    // quality of 140 is a typo, and silently encoding at 100 would hide it.
1771    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    // A listener that never binds never hands a client its names either, so
1792    // a stale key there is not worth refusing the boot over - the call the
1793    // pre-split section made for every key at once.
1794    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    // Per-model parameters are read by the exact spelling a project's
1818    // `model` pins - not by what accounts declare - so a name no project
1819    // pins is a statement nothing will act on. Warned rather than refused:
1820    // a project may pin the model on a later edit, and the table entry is
1821    // how it would be there waiting.
1822    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    // A speaking model nothing installs is a boot failure, the way an
1845    // unknown `[dictate]` model key is.
1846    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
1880/// A stable 64-bit fingerprint of the file's raw text, in hex. The settings
1881/// page carries the fingerprint it loaded and a save is refused when the file
1882/// has moved since.
1883pub(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
1892/// A project's env: the `env_file` entries with the inline `env` table
1893/// layered over them, since the inline form is the more explicit
1894/// statement of the two.
1895/// Trim the setup token a project's env declares: the spawned child
1896/// reads that map verbatim, so a padded value would fail to
1897/// authenticate. The account layers never reach here with one - the
1898/// load drops the gateway keys before they merge.
1899fn 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
1916/// Parse `KEY=value` lines, skipping blanks and `#` comments. Every way
1917/// this can go wrong warns and yields the keys it did read - a project's
1918/// env file is not worth refusing to boot over, and a partial read is
1919/// visible in the per-spawn applied record's key list.
1920fn 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        // A relative path resolves against the process working
1937        // directory, so the same config would read a different file per
1938        // launch directory (HR#14). Skipping is failing the operation;
1939        // what the rule forbids is substituting a cwd-derived answer.
1940        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                // Strip one matching pair of surrounding quotes, as
1960                // dotenv and direnv do. TOML forces quoting, so a value
1961                // moved out of the project's env table arrives with
1962                // them, and keeping them yields a token silently longer
1963                // than intended that fails far from the cause.
1964                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    /// Resolve a project by name for the `session_env` calls below.
1996    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    /// Write `forge/forge.toml` (the production location).
2001    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    /// One logged event: its level and the `event_name` it carries. A
2025    /// config that declares something forge ignores must warn at WARN with
2026    /// its own name, and the level is half of that: these targets are not
2027    /// raised by the default filters, so a warn demoted to debug is a warn
2028    /// that stopped being seen.
2029    #[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        /// Whether `event` was logged, at WARN.
2058        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        /// Every event name logged, in order.
2067        fn names(&self) -> Vec<String> {
2068            self.0.lock().expect("capture").iter().map(|(_, name)| name.clone()).collect()
2069        }
2070    }
2071
2072    /// Load `dir`, capturing every `event_name` the load logs.
2073    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    /// `[gateway] port = 0` is refused at load: the port is fixed, and
2084    /// a 0 would ask the OS for an ephemeral port no child was told
2085    /// about.
2086    #[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    /// The document ceiling's default, stated where the key lives: an absent
2165    /// `[documents]` section means 10 MiB.
2166    #[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    /// `max_size_mib = 0` is refused at load: it would refuse every document
2203    /// the moment one is handed, which reads as a bug rather than a choice.
2204    #[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    /// **A ceiling past what the wire can carry is refused at load, with
2236    /// the reason in the error.** A document crosses the socket
2237    /// base64-encoded and the desktop client's frame cap is 256 MiB, so a
2238    /// ceiling above [`DOCUMENT_MAX_MIB_LIMIT`] would read, raise, and then
2239    /// die at the client - after a full read the reader never sees. The
2240    /// limit itself is usable.
2241    #[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    /// The usage poller's interval default, stated where the key lives: an
2269    /// absent `[usage]` section means 60 s.
2270    #[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    /// `poll_seconds = 0` is refused at load: a zero interval would probe
2287    /// continuously.
2288    #[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    /// An absent `[sessions]` table takes the four settled defaults: healing
2311    /// on, three restarts at 5 s, 30 s and 2 min, and a 10 min stable window.
2312    #[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    /// A backoff list shorter than the attempt count repeats its last
2349    /// interval, so one entry covers every restart.
2350    #[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    /// Zero restart attempts would leave a dead seat dead while claiming
2365    /// healing is on, so it is refused by name.
2366    #[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    /// A duration with no unit is a typo, not seconds: reading "10" as 10 s
2378    /// would silently heal slower or faster than the file says.
2379    #[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    /// On unless it is turned off, and on loopback: "on by default" must
2394    /// not mean every machine starts listening on a network interface.
2395    #[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        // Two assertions, two changes: one drifts the const from the number
2402        // the book's table documents, the other lets the Default impl drift
2403        // from the const.
2404        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    /// An absent `[client]` leaves every name unset, and unset means the
2426    /// built-in rather than a pinned value.
2427    #[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    /// **`[browser]` carries the encoding and the idle release.** An absent
2451    /// section is the documented pair of defaults; a key outside its range
2452    /// is refused rather than clamped, because a quality of 140 is a typo
2453    /// and silently encoding at 100 would hide it.
2454    #[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    /// **A profile key resolves per project**: the org's `browser_profile`
2494    /// is the fallback, a project's own key replaces it, and an org that
2495    /// declares neither resolves to `<org>/default`.
2496    #[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    /// The idle default is half an hour, and the grace key is gone: the
2588    /// next spawn is what picks a closed profile up, so there is no owner
2589    /// left to warn and nothing for a second clock to do.
2590    #[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    /// A section that never binds cannot stop the boot: a stale port on a
2601    /// disabled listener is the config's business only if something would
2602    /// use it, and nothing will. Nor are the client's names read: a listener
2603    /// that never binds never hands a client its settings either, which is
2604    /// the call the pre-split section made for every key at once.
2605    #[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    /// A browser is pointed at this port by hand, so an OS-assigned one
2640    /// is unguessable rather than merely unhelpful.
2641    #[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    /// Compared against the gateway's resolved port rather than its
2653    /// default: whichever listener binds second loses, and the listener
2654    /// being on by default means the loss reads as the listener broken.
2655    #[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    /// A name picks from the set forge ships, and a name outside it stops
2671    /// the boot rather than falling back to the built-in: a setting that is
2672    /// quietly ignored reads as the key not working.
2673    #[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        // Every name key validates the same way, so one walk covers them
2691        // rather than three near-identical tests.
2692        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    /// **A retired section is an unknown key now.** The ghosts are gone, so
2714    /// a stale synced forge.toml fails the load naming the section rather
2715    /// than being accepted and silently ignored.
2716    #[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        // An absurd magnitude is refused with the same error: silently
2742        // mapping it to the default would substitute the user's value.
2743        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    /// `https://openrouter.ai/v1/key` answers 200 with a marketing page,
2872    /// and 200 is the one status the probe treats as success, so a bare
2873    /// host reaches the decode arm, retries twelve times and bails the
2874    /// account - which stops forge starting. Catch the base at load
2875    /// instead, where the user can act on it.
2876    #[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        // A near-miss key is the failure `provider` exists to prevent:
3148        // without the reject it loads, probes the wrong endpoint and
3149        // hangs preflight.
3150        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        // The near-miss matters: a project key that loaded as nothing
3179        // would silently drop whatever behaviour it configured.
3180        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    /// The loader runs the checks the boot's Slack build runs, so a reload
3249    /// cannot accept a list a restart refuses.
3250    #[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    /// A project's own binding is the narrower statement, so it wins over
3282    /// the org's: the org key exists to save every project repeating one.
3283    #[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    /// The org key's whole point: a project that names nothing inherits it.
3324    #[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    /// A binding that names no `[[slack]]` entry would boot with every
3361    /// Slack tool dead, so it is refused, naming the label it asked for and
3362    /// the ones that exist.
3363    #[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    /// A padded label is the same label: `[[slack]]` labels are matched
3430    /// trimmed, so a binding is too, and the stored value is the trimmed
3431    /// one every later lookup uses.
3432    #[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    /// A binding with no `[[slack]]` section at all is still refused, and
3467    /// the message says there is nothing to bind to rather than printing an
3468    /// empty list.
3469    #[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        // The flat token maps onto the provider's credential variable -
3531        // the only key an account without [accounts.env] carries.
3532        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    /// A gateway key declared in an env layer is dropped rather than
3541    /// refused, and dropped from every layer, so it cannot reach the
3542    /// pool. The stamp covers the child's env only, and the pool's env
3543    /// is what the forward leg reads the upstream and the credential
3544    /// out of: a base URL left there would send real traffic, with the
3545    /// real credential attached, somewhere the config never named.
3546    #[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        // The scrub runs before the flat keys are mapped, so a base-url
3592        // account keeps its own endpoint rather than losing it too.
3593        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    /// A `{:?}` on the loaded config or on the raw `ForgeToml` must
3602    /// print no credential: the global env, the accounts' env stamps and
3603    /// flat token, a project's env values, the gotify client token and the
3604    /// systemone api key all stay out of it.
3605    #[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    /// The four sections that used to ignore an unknown key. A
3903    /// misspelled `fallback_accounts` parses clean as "no fallbacks",
3904    /// which is a silent behaviour change from a typo rather than a
3905    /// refused boot.
3906    #[test]
3907    fn mistyped_keys_are_rejected_in_every_section() {
3908        let base = minimal_config();
3909        // (label, config text with the near-miss, the key the error must name)
3910        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        // Collected rather than asserted one at a time: a section that
3940        // started ignoring keys again should be named alongside the
3941        // rest, not hide behind the first.
3942        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        // The probe reads this map through `token_bearer` and the spawn
4078        // path stamps it onto the child verbatim; both must see the
4079        // same credential.
4080        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    /// One key per precedence boundary, so a test can assert a single
4128    /// boundary and a future reordering of the merge fails on exactly
4129    /// the boundary it broke.
4130    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    /// `session_env` for the fixture's single project + account.
4156    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    /// Every near-miss shape for declaring project env. Each loads
4165    /// clean and applies nothing without the `deny_unknown_fields`
4166    /// attributes; the point of the table is that they are all one
4167    /// class rather than five separate bugs.
4168    #[test]
4169    fn near_miss_env_declarations_are_rejected() {
4170        // (label, stanza appended to the base config, text the error must name)
4171        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    /// Write an env file and a config pointing at it. Returns the dir
4181    /// so it outlives the load.
4182    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        // The inline parameter is an `env` entry fragment when set: the
4187        // collocated shape carries the env inline table on the entry.
4188        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        // Quoted is the shape a value moved out of forge.toml arrives in.
4228        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    /// An env block naming a project that no `[[orgs.projects]]`
4316    /// declares is the same typo class as an org naming an undeclared
4317    /// account, which `load_from_dir` already refuses to boot on. A
4318    /// silently-ignored env block is the failure mode #551 exists to
4319    /// kill, so it must not load.
4320    /// One assertion per precedence boundary, so a reordering fails on
4321    /// the boundary it broke rather than on a single opaque test.
4322    #[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    /// **A seat's children start from the server's own environment.** The
4348    /// `claude` child inherits it; a terminal's shell is handed a map, so
4349    /// without this set it has no `HOME` and the shell integration reads the
4350    /// person's rc from nowhere. The config tables still win over it.
4351    #[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    /// Two projects on one account, one declaring env. The other must
4368    /// not receive it.
4369    #[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    /// The collocation's load-bearing mechanic: each block-form env
4416    /// attaches to the most recent `[[orgs.projects]]` header, so two
4417    /// env blocks under two entries land on their OWN projects. The
4418    /// single-project form cannot pin this - with one project there is
4419    /// nothing to mis-attach to.
4420    #[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    /// The `[dictate]` plumbing leg, which the section's own serde tests
4493    /// cannot reach: a `parsed.dictate` never threaded into
4494    /// `LoadedConfig` leaves dictation off however the user writes
4495    /// forge.toml, and every one of those tests still passes.
4496    #[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    /// The speaking model and the voice reach the loaded config, and a model
4514    /// nothing installs refuses the boot by the name the file used.
4515    #[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    /// The account's email is the salt the master key derives with, so it is
4626    /// not printed in a Debug line about the section.
4627    #[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    /// **A parse failure prints neither the line it failed on nor the value
4883    /// it read** (#2267): the error used to render `toml`'s snippet of the
4884    /// offending line, so a mistyped key on the token's own line echoed the
4885    /// token to stderr on the next boot. The key, the reason and the
4886    /// position are the three things a reader fixes it with; the value is
4887    /// not one of them.
4888    #[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    /// The other shape the snippet padded out: serde's own words quote the
4903    /// VALUE of a type mismatch (`invalid type: string "…"`), and those
4904    /// words survive the snippet's removal - so they are scrubbed too, while
4905    /// a key name (backtick-quoted) stays legible.
4906    #[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    /// The position counts characters, not bytes, and a key is named only
4918    /// when the line actually has one: a malformed array element or a bare
4919    /// value has no `=` (or its head is not key-shaped), and quoting
4920    /// whatever sits there would put a value back in the message - the leak
4921    /// this change exists to close.
4922    #[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        // A multi-byte character before the span: `é` is two bytes and one
4926        // column, and its line has no bare key, so no key prints either.
4927        assert_eq!(parse_position("caf\u{e9} = 1\n", Some(8..9)), " at line 1, column 8",);
4928        // A line with no `=`, and a head that is not key-shaped: nothing is
4929        // named rather than a value quoted as a key.
4930        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    /// An unterminated quote takes its tail with it: the tail is the value
4935    /// whose end nobody found.
4936    #[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    /// The renamed key's predecessor is refused by name, so a stale config
5205    /// cannot boot with its pin silently dropped by an ignored field.
5206    #[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    /// The `[models."<name>"]` table parses by the spelling a project's
5253    /// `model` pins and reaches the loaded config; an absent table leaves
5254    /// the map empty, so every spawn stays on the CLI's own resolution.
5255    #[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    /// A window that is not positive is refused by name, pointing at the
5308    /// model whose entry carries it: a zero or negative window would leave
5309    /// the CLI compacting on every turn.
5310    #[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    /// A project's table carries only what it disagrees on, and every key it
5352    /// leaves out is an absent value rather than a default baked in at parse
5353    /// time - that absence is what lets the top-level table show through.
5354    #[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    /// A share at either end of the range is a trigger that never fires or
5398    /// one that fires on the first turn, and a value past 255 is refused
5399    /// here rather than by serde, so it is refused by name with the fix.
5400    #[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    /// The backstop's zero would swap the seat on every turn, which is not
5429    /// what the key means, and the project's own table is the one named.
5430    #[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    /// Every key the load ignores is warned about, at WARN, by its own
5486    /// event name. A config that declares something forge no longer
5487    /// reads must not go quiet, and the level is half of that: these
5488    /// targets are not raised by the default filters, so a warn demoted
5489    /// to debug is a warn that stopped being seen.
5490    #[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    /// The `[models]` table is read by the exact spelling a project's
5524    /// `model` pins, not by what accounts declare: the pinned spelling is
5525    /// the entry that acts and warns nothing, and the account-facing
5526    /// spelling of the same model warns even though an account declares it.
5527    #[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    /// A mistyped key inside a `[models]` table is refused at load rather
5586    /// than silently applying nothing.
5587    #[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    /// A relative `document_roots` entry is refused at load, naming the key
5621    /// and the entry: a relative root would resolve against whatever
5622    /// working directory the read happened in, which is nobody's answer.
5623    /// `~user/Madness` is the same refusal, and the sentence names the
5624    /// remedy it accepts (`~` and `~/`).
5625    #[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    /// `document_roots` defaults to empty; an entry expands `~` and a bare
5665    /// `~` is home, while an absolute one stays as itself.
5666    #[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}