Skip to main content

forge_workspace/
settings.rs

1//! The class catalogue and the effective-config document.
2//!
3//! One table lists every key the loader accepts, the area it draws in and
4//! when an edit to it takes effect; each entry also resolves its own value
5//! from a `LoadedConfig`. The reload summary and the settings page both
6//! read that table, so a key cannot be live in one and next-session in the
7//! other.
8
9use std::collections::BTreeSet;
10use std::path::Path;
11
12use forge_primitives::settings::SettingsTotals;
13use forge_primitives::{
14    EditKind, EffectClass, SettingKind, SettingsArea, SettingsDocument, SettingsEdit, SettingsKey,
15};
16use serde_json::json;
17
18use crate::config::{
19    ConfigReload, DEFAULT_HANDOFF_AFTER_COMPACTIONS, DEFAULT_HANDOFF_AT_PERCENT,
20    DEFAULT_MAX_WORKERS_PER_PROJECT, LoadedConfig, LoadedProject, format_session_duration,
21};
22
23/// One area's fixed identity: the id the page keys on, the name it draws,
24/// and the line under it.
25pub struct AreaSpec {
26    pub id: &'static str,
27    pub name: &'static str,
28    pub note: &'static str,
29}
30
31/// The sub-nav's areas, in the order it draws them. `[browser]` rides with
32/// the server that hosts it; `[dictate]` and `[tts]` share the voice area,
33/// because they are the two directions of one conversation.
34pub const AREAS: &[AreaSpec] = &[
35    AreaSpec {
36        id: "accounts",
37        name: "accounts",
38        note: "the provider endpoints and credentials a session can spawn under",
39    },
40    AreaSpec {
41        id: "projects",
42        name: "projects",
43        note: "orgs, their projects, and every per-project setting",
44    },
45    AreaSpec {
46        id: "sessions",
47        name: "sessions",
48        note: "whether a dead lead or worker comes back, and how often it tries",
49    },
50    AreaSpec {
51        id: "connectors",
52        name: "connectors",
53        note: "gotify, slack, system one, the vault and the plugin switch - the integrations that reach outside forge",
54    },
55    AreaSpec {
56        id: "server",
57        name: "server",
58        note: "the socket a client reads through, the browser the server hosts, and the \
59               seat's terminal",
60    },
61    AreaSpec { id: "client", name: "client", note: "the sets a client draws with" },
62    AreaSpec {
63        id: "voice",
64        name: "voice",
65        note: "the two directions: a take becomes text, and a reply becomes speech",
66    },
67    AreaSpec {
68        id: "models",
69        name: "models",
70        note: "per-model parameters stamped onto a spawned child",
71    },
72    AreaSpec {
73        id: "handoff",
74        name: "handoff",
75        note: "when a session swaps itself for a fresh one",
76    },
77    AreaSpec {
78        id: "documents",
79        name: "documents",
80        note: "the ceiling on one document a session hands you",
81    },
82    AreaSpec { id: "usage", name: "usage", note: "how often the account pool refreshes its usage" },
83    AreaSpec {
84        id: "gateway",
85        name: "gateway",
86        note: "the inference listener sessions are pointed at",
87    },
88    AreaSpec { id: "env", name: "env", note: "variables stamped onto every spawned child" },
89];
90
91/// One row a catalogued key resolves to: a concrete path (with the instance
92/// where the table repeats) and the effective value.
93#[derive(Clone, PartialEq)]
94struct KeyRow {
95    path: String,
96    kind: SettingKind,
97    value: serde_json::Value,
98}
99
100fn text(path: impl Into<String>, value: impl Into<String>) -> KeyRow {
101    KeyRow { path: path.into(), kind: SettingKind::String, value: json!(value.into()) }
102}
103
104/// An optional setting's raw value: the value when the file set it, and
105/// `null` when it did not. The effective built-in is the page's to draw, so
106/// the client's axes path never reads a display default back as a setting.
107fn text_set(path: impl Into<String>, value: Option<String>) -> KeyRow {
108    KeyRow {
109        path: path.into(),
110        kind: SettingKind::String,
111        value: value.map_or(serde_json::Value::Null, serde_json::Value::String),
112    }
113}
114
115fn number(path: impl Into<String>, value: impl serde::Serialize) -> KeyRow {
116    KeyRow { path: path.into(), kind: SettingKind::Number, value: json!(value) }
117}
118
119fn flag(path: impl Into<String>, value: bool) -> KeyRow {
120    KeyRow { path: path.into(), kind: SettingKind::Bool, value: json!(value) }
121}
122
123fn list(path: impl Into<String>, values: Vec<String>) -> KeyRow {
124    let value =
125        serde_json::Value::Array(values.into_iter().map(serde_json::Value::String).collect());
126    KeyRow { path: path.into(), kind: SettingKind::List, value }
127}
128
129/// One key's edit metadata, in the catalogue's own static form.
130#[derive(Clone, Copy)]
131struct EditSpec {
132    kind: EditKind,
133    allowed: &'static [&'static str],
134    required: bool,
135    group: &'static str,
136}
137
138const TEXT: EditSpec =
139    EditSpec { kind: EditKind::String, allowed: &[], required: false, group: "" };
140const NUMBER: EditSpec =
141    EditSpec { kind: EditKind::Number, allowed: &[], required: false, group: "" };
142const BOOLEAN: EditSpec =
143    EditSpec { kind: EditKind::Bool, allowed: &[], required: false, group: "" };
144const LIST: EditSpec = EditSpec { kind: EditKind::List, allowed: &[], required: false, group: "" };
145
146const PERMISSION_MODES: &[&str] =
147    &["default", "acceptEdits", "plan", "dontAsk", "auto", "bypassPermissions"];
148const DICTATE_BINDS: &[&str] = &["right_cmd", "left_cmd", "off"];
149const DICTATE_MODES: &[&str] = &["auto", "toggle", "hold"];
150const DICTATE_STYLINGS: &[&str] = &["casual", "semi_casual", "semi_formal", "formal"];
151const DICTATE_STRUCTURES: &[&str] = &["prose", "lists"];
152const DICTATE_CONTEXTS: &[&str] = &["general", "email"];
153const PROVIDERS: &[&str] = &["anthropic", "codex", "openrouter", "zai"];
154
155const fn named(spec: EditSpec, group: &'static str) -> EditSpec {
156    EditSpec { kind: spec.kind, allowed: spec.allowed, required: spec.required, group }
157}
158
159const fn choice(allowed: &'static [&'static str]) -> EditSpec {
160    EditSpec { kind: EditKind::Enum, allowed, required: false, group: "" }
161}
162
163const fn required(mut spec: EditSpec) -> EditSpec {
164    spec.required = true;
165    spec
166}
167
168/// The control and the entry a catalogued key belongs to. The default is a
169/// plain text field; a numeric, boolean, list or choice key is named here, and
170/// a test holds each edit kind to the value kind the resolver draws.
171fn edit_spec(path: &str) -> EditSpec {
172    match path {
173        // accounts
174        "accounts[].display_name" | "accounts[].token" => named(required(TEXT), "account"),
175        "accounts[].provider" => named(required(choice(PROVIDERS)), "account"),
176        "accounts[].base_url" | "accounts[].model_slugs.*" | "accounts[].env.*" => {
177            named(TEXT, "account")
178        }
179        "accounts[].models" => named(required(LIST), "account"),
180        "accounts[].model_aliases.*" => named(LIST, "account"),
181        // orgs
182        "orgs[].name" => named(required(TEXT), "org"),
183        "orgs[].accounts" => named(required(LIST), "org"),
184        "orgs[].fallback_accounts" => named(LIST, "org"),
185        "orgs[].browser_profile" | "orgs[].slack.workspace" => named(TEXT, "org"),
186        // projects
187        "orgs[].projects[].name" | "orgs[].projects[].path" | "orgs[].projects[].model" => {
188            named(required(TEXT), "project")
189        }
190        "orgs[].projects[].handoff.enabled" => named(BOOLEAN, "project"),
191        "orgs[].projects[].permission_mode" => named(choice(PERMISSION_MODES), "project"),
192        "orgs[].projects[].max_workers"
193        | "orgs[].projects[].handoff.at_percent"
194        | "orgs[].projects[].handoff.after_compactions" => named(NUMBER, "project"),
195        "orgs[].projects[].env.*"
196        | "orgs[].projects[].env_file"
197        | "orgs[].projects[].browser_profile"
198        | "orgs[].projects[].slack.workspace" => named(TEXT, "project"),
199        "orgs[].projects[].document_roots" => named(LIST, "project"),
200        // connectors
201        "gotify.url"
202        | "gotify.client_token"
203        | "systemone.base_url"
204        | "systemone.model"
205        | "terminal.shell"
206        | "vault.url"
207        | "vault.email"
208        | "server.bind" => required(TEXT),
209        "slack[].workspace" | "slack[].token" => named(required(TEXT), "slack"),
210        "slack[].poll_seconds"
211        | "slack[].dm_poll_seconds"
212        | "slack[].thread_idle_days"
213        | "slack[].stale_after_hours" => named(NUMBER, "slack"),
214        "systemone.enabled"
215        | "plugins.auto_update"
216        | "server.enabled"
217        | "dictate.enabled"
218        | "dictate.normalizer"
219        | "tts.enabled"
220        | "handoff.enabled"
221        | "sessions.heal" => BOOLEAN,
222        "systemone.timeout_ms"
223        | "server.first_frame_cap_kb"
224        | "server.more_page_cap_kb"
225        | "server.tool_record_cap_kb"
226        | "browser.fps"
227        | "browser.quality"
228        | "browser.idle_minutes"
229        | "terminal.scrollback_lines"
230        | "terminal.idle_minutes"
231        | "dictate.max_capture_minutes"
232        | "tts.max_utterance_seconds"
233        | "handoff.at_percent"
234        | "handoff.after_compactions"
235        | "vault.idle_minutes" => NUMBER,
236        "server.allowed_origins" | "sessions.heal_backoff" => LIST,
237        // client
238        "client.mark" => choice(forge_primitives::client::MARK_NAMES),
239        "client.theme" => choice(forge_primitives::client::THEME_NAMES),
240        "client.font" => choice(forge_primitives::client::FONT_NAMES),
241        // dictate
242        "dictate.bind" => choice(DICTATE_BINDS),
243        "dictate.mode" => choice(DICTATE_MODES),
244        "dictate.styling" => choice(DICTATE_STYLINGS),
245        "dictate.structure" => choice(DICTATE_STRUCTURES),
246        "dictate.context" => choice(DICTATE_CONTEXTS),
247        // models, handoff, documents, usage, gateway
248        "models.*.auto_compact_window" => named(NUMBER, "model"),
249        "documents.max_size_mib"
250        | "usage.poll_seconds"
251        | "sessions.heal_attempts"
252        | "server.port"
253        | "gateway.port"
254        | "gateway.streak_count"
255        | "gateway.streak_window_secs"
256        | "gateway.no_reset_cooldown_secs" => required(NUMBER),
257        // the text keys and anything a later catalogue line adds
258        _ => TEXT,
259    }
260}
261
262fn edit_wire(spec: EditSpec) -> SettingsEdit {
263    SettingsEdit {
264        kind: spec.kind,
265        allowed: spec.allowed.iter().map(|value| (*value).to_owned()).collect(),
266        required: spec.required,
267        group: (!spec.group.is_empty()).then(|| spec.group.to_owned()),
268    }
269}
270
271/// One accepted key: its pattern, the area it draws in, its class, and the
272/// rows it resolves to for a load.
273pub struct ConfigKey {
274    pub path: &'static str,
275    pub area: &'static str,
276    pub class: EffectClass,
277    rows: fn(&LoadedConfig) -> Vec<KeyRow>,
278}
279
280fn all_keys() -> impl Iterator<Item = &'static ConfigKey> {
281    ACCOUNT_KEYS
282        .iter()
283        .chain(PROJECT_KEYS)
284        .chain(CONNECTOR_KEYS)
285        .chain(SERVER_KEYS)
286        .chain(TERMINAL_KEYS)
287        .chain(CLIENT_KEYS)
288        .chain(DICTATE_KEYS)
289        .chain(TTS_KEYS)
290        .chain(MODEL_KEYS)
291        .chain(SESSION_KEYS)
292        .chain(HANDOFF_KEYS)
293        .chain(DOCUMENT_KEYS)
294        .chain(USAGE_KEYS)
295        .chain(GATEWAY_KEYS)
296        .chain(ENV_KEYS)
297}
298
299fn provider_label(provider: forge_primitives::account::Provider) -> &'static str {
300    match provider {
301        forge_primitives::account::Provider::Anthropic => "anthropic",
302        forge_primitives::account::Provider::Codex => "codex",
303        forge_primitives::account::Provider::Openrouter => "openrouter",
304        forge_primitives::account::Provider::Zai => "zai",
305    }
306}
307
308fn org_names(config: &LoadedConfig) -> Vec<&str> {
309    let mut seen = BTreeSet::new();
310    config.projects.iter().filter(|p| seen.insert(p.org.as_str())).map(|p| p.org.as_str()).collect()
311}
312
313fn org_project<'a>(config: &'a LoadedConfig, org: &str) -> Option<&'a LoadedProject> {
314    config.projects.iter().find(|p| p.org == org)
315}
316
317fn sorted_keys<V>(map: &std::collections::HashMap<String, V>) -> Vec<&String> {
318    let mut keys: Vec<&String> = map.keys().collect();
319    keys.sort_unstable();
320    keys
321}
322
323const ACCOUNT_KEYS: &[ConfigKey] = &[
324    ConfigKey {
325        path: "accounts[].display_name",
326        area: "accounts",
327        class: EffectClass::Live,
328        rows: |config| {
329            config
330                .accounts
331                .iter()
332                .map(|a| text(format!("accounts.{}.display_name", a.display_name), &a.display_name))
333                .collect()
334        },
335    },
336    ConfigKey {
337        path: "accounts[].provider",
338        area: "accounts",
339        class: EffectClass::Live,
340        rows: |config| {
341            config
342                .accounts
343                .iter()
344                .map(|a| {
345                    text(
346                        format!("accounts.{}.provider", a.display_name),
347                        provider_label(a.provider),
348                    )
349                })
350                .collect()
351        },
352    },
353    ConfigKey {
354        path: "accounts[].base_url",
355        area: "accounts",
356        class: EffectClass::Live,
357        rows: |config| {
358            config
359                .accounts
360                .iter()
361                .map(|a| {
362                    text(
363                        format!("accounts.{}.base_url", a.display_name),
364                        a.base_url.clone().unwrap_or_else(|| "not set".to_owned()),
365                    )
366                })
367                .collect()
368        },
369    },
370    ConfigKey {
371        path: "accounts[].token",
372        area: "accounts",
373        class: EffectClass::Live,
374        rows: |config| {
375            config
376                .accounts
377                .iter()
378                .map(|a| {
379                    let variable = forge_gateway::binding::credential_variable_for(a.provider);
380                    text(
381                        format!("accounts.{}.token", a.display_name),
382                        a.env.get(variable).cloned().unwrap_or_else(|| "not set".to_owned()),
383                    )
384                })
385                .collect()
386        },
387    },
388    ConfigKey {
389        path: "accounts[].models",
390        area: "accounts",
391        class: EffectClass::Live,
392        rows: |config| {
393            config
394                .accounts
395                .iter()
396                .map(|a| list(format!("accounts.{}.models", a.display_name), a.models.clone()))
397                .collect()
398        },
399    },
400    ConfigKey {
401        path: "accounts[].model_slugs.*",
402        area: "accounts",
403        class: EffectClass::Live,
404        rows: |config| {
405            let mut rows = Vec::new();
406            for account in &config.accounts {
407                for name in sorted_keys(&account.model_slugs) {
408                    rows.push(text(
409                        format!("accounts.{}.model_slugs.{name}", account.display_name),
410                        &account.model_slugs[name],
411                    ));
412                }
413            }
414            rows
415        },
416    },
417    ConfigKey {
418        path: "accounts[].model_aliases.*",
419        area: "accounts",
420        class: EffectClass::Live,
421        rows: |config| {
422            let mut rows = Vec::new();
423            for account in &config.accounts {
424                for name in sorted_keys(&account.model_aliases) {
425                    rows.push(list(
426                        format!("accounts.{}.model_aliases.{name}", account.display_name),
427                        account.model_aliases[name].clone(),
428                    ));
429                }
430            }
431            rows
432        },
433    },
434    ConfigKey {
435        path: "accounts[].env.*",
436        area: "accounts",
437        class: EffectClass::NextSession,
438        rows: |config| {
439            let mut rows = Vec::new();
440            for account in &config.accounts {
441                let credential = forge_gateway::binding::credential_variable_for(account.provider);
442                for name in sorted_keys(&account.env) {
443                    let value = &account.env[name];
444                    if name == "ANTHROPIC_BASE_URL"
445                        || name == credential
446                        || config.global_env.get(name) == Some(value)
447                    {
448                        continue;
449                    }
450                    rows.push(text(format!("accounts.{}.env.{name}", account.display_name), value));
451                }
452            }
453            rows
454        },
455    },
456];
457const PROJECT_KEYS: &[ConfigKey] = &[
458    ConfigKey {
459        path: "orgs[].name",
460        area: "projects",
461        class: EffectClass::Live,
462        rows: |config| {
463            org_names(config).into_iter().map(|org| text(format!("orgs.{org}.name"), org)).collect()
464        },
465    },
466    ConfigKey {
467        path: "orgs[].accounts",
468        area: "projects",
469        class: EffectClass::Live,
470        rows: |config| {
471            org_names(config)
472                .into_iter()
473                .filter_map(|org| {
474                    org_project(config, org)
475                        .map(|p| list(format!("orgs.{org}.accounts"), p.accounts.clone()))
476                })
477                .collect()
478        },
479    },
480    ConfigKey {
481        path: "orgs[].fallback_accounts",
482        area: "projects",
483        class: EffectClass::Live,
484        rows: |config| {
485            org_names(config)
486                .into_iter()
487                .filter_map(|org| {
488                    org_project(config, org).map(|p| {
489                        list(format!("orgs.{org}.fallback_accounts"), p.fallback_accounts.clone())
490                    })
491                })
492                .collect()
493        },
494    },
495    ConfigKey {
496        path: "orgs[].browser_profile",
497        area: "projects",
498        class: EffectClass::Live,
499        rows: |config| {
500            org_names(config)
501                .into_iter()
502                .filter_map(|org| {
503                    config
504                        .org_browser_profiles
505                        .get(org)
506                        .map(|name| text(format!("orgs.{org}.browser_profile"), name))
507                })
508                .collect()
509        },
510    },
511    ConfigKey {
512        path: "orgs[].slack.workspace",
513        area: "projects",
514        class: EffectClass::Live,
515        rows: |config| {
516            org_names(config)
517                .into_iter()
518                .filter_map(|org| {
519                    config
520                        .org_slack
521                        .get(org)
522                        .map(|workspace| text(format!("orgs.{org}.slack.workspace"), workspace))
523                })
524                .collect()
525        },
526    },
527    ConfigKey {
528        path: "orgs[].projects[].name",
529        area: "projects",
530        class: EffectClass::Live,
531        rows: |config| {
532            config.projects.iter().map(|p| text(project_path(p, "name"), &p.name)).collect()
533        },
534    },
535    ConfigKey {
536        path: "orgs[].projects[].path",
537        area: "projects",
538        class: EffectClass::Live,
539        rows: |config| {
540            config.projects.iter().map(|p| text(project_path(p, "path"), &p.display_path)).collect()
541        },
542    },
543    ConfigKey {
544        path: "orgs[].projects[].model",
545        area: "projects",
546        class: EffectClass::NextSession,
547        rows: |config| {
548            config
549                .projects
550                .iter()
551                .map(|p| {
552                    text(
553                        project_path(p, "model"),
554                        p.model.clone().unwrap_or_else(|| "not set".to_owned()),
555                    )
556                })
557                .collect()
558        },
559    },
560    ConfigKey {
561        path: "orgs[].projects[].permission_mode",
562        area: "projects",
563        class: EffectClass::NextSession,
564        rows: |config| {
565            config
566                .projects
567                .iter()
568                .map(|p| text(project_path(p, "permission_mode"), p.permission_mode.as_wire()))
569                .collect()
570        },
571    },
572    ConfigKey {
573        path: "orgs[].projects[].max_workers",
574        area: "projects",
575        class: EffectClass::NextSession,
576        rows: |config| {
577            config
578                .projects
579                .iter()
580                .map(|p| {
581                    number(
582                        project_path(p, "max_workers"),
583                        p.max_workers.unwrap_or(DEFAULT_MAX_WORKERS_PER_PROJECT),
584                    )
585                })
586                .collect()
587        },
588    },
589    ConfigKey {
590        path: "orgs[].projects[].env.*",
591        area: "projects",
592        class: EffectClass::NextSession,
593        rows: |config| {
594            let mut rows = Vec::new();
595            for project in &config.projects {
596                for name in sorted_keys(&project.env) {
597                    rows.push(text(
598                        project_path(project, &format!("env.{name}")),
599                        &project.env[name],
600                    ));
601                }
602            }
603            rows
604        },
605    },
606    ConfigKey {
607        path: "orgs[].projects[].env_file",
608        area: "projects",
609        class: EffectClass::NextSession,
610        rows: |config| {
611            config
612                .projects
613                .iter()
614                .filter_map(|p| {
615                    p.env_file.as_ref().map(|path| text(project_path(p, "env_file"), path))
616                })
617                .collect()
618        },
619    },
620    ConfigKey {
621        path: "orgs[].projects[].document_roots",
622        area: "projects",
623        class: EffectClass::Live,
624        rows: |config| {
625            config
626                .projects
627                .iter()
628                .map(|p| {
629                    list(
630                        project_path(p, "document_roots"),
631                        p.document_roots.iter().map(|root| root.display().to_string()).collect(),
632                    )
633                })
634                .collect()
635        },
636    },
637    ConfigKey {
638        path: "orgs[].projects[].handoff.enabled",
639        area: "projects",
640        class: EffectClass::Live,
641        rows: |config| {
642            config
643                .projects
644                .iter()
645                .filter_map(|project| {
646                    let own = project.handoff?;
647                    let policy = crate::config::HandoffPolicy::resolve(&config.handoff, Some(&own));
648                    Some(flag(project_path(project, "handoff.enabled"), policy.enabled))
649                })
650                .collect()
651        },
652    },
653    ConfigKey {
654        path: "orgs[].projects[].handoff.at_percent",
655        area: "projects",
656        class: EffectClass::Live,
657        rows: |config| {
658            config
659                .projects
660                .iter()
661                .filter_map(|project| {
662                    let own = project.handoff?;
663                    let policy = crate::config::HandoffPolicy::resolve(&config.handoff, Some(&own));
664                    Some(number(project_path(project, "handoff.at_percent"), policy.at_percent))
665                })
666                .collect()
667        },
668    },
669    ConfigKey {
670        path: "orgs[].projects[].handoff.after_compactions",
671        area: "projects",
672        class: EffectClass::Live,
673        rows: |config| {
674            config
675                .projects
676                .iter()
677                .filter_map(|project| {
678                    let own = project.handoff?;
679                    let policy = crate::config::HandoffPolicy::resolve(&config.handoff, Some(&own));
680                    Some(number(
681                        project_path(project, "handoff.after_compactions"),
682                        policy.after_compactions,
683                    ))
684                })
685                .collect()
686        },
687    },
688    ConfigKey {
689        path: "orgs[].projects[].browser_profile",
690        area: "projects",
691        class: EffectClass::NextSession,
692        rows: |config| {
693            config
694                .projects
695                .iter()
696                .map(|p| text(project_path(p, "browser_profile"), p.browser_profile.name()))
697                .collect()
698        },
699    },
700    ConfigKey {
701        path: "orgs[].projects[].slack.workspace",
702        area: "projects",
703        class: EffectClass::Live,
704        rows: |config| {
705            config
706                .projects
707                .iter()
708                .filter_map(|p| {
709                    p.slack_workspace
710                        .as_ref()
711                        .map(|workspace| text(project_path(p, "slack.workspace"), workspace))
712                })
713                .collect()
714        },
715    },
716];
717
718fn project_path(project: &LoadedProject, key: &str) -> String {
719    format!("orgs.{}.projects.{}.{key}", project.org, project.name)
720}
721const CONNECTOR_KEYS: &[ConfigKey] = &[
722    ConfigKey {
723        path: "huggingface.token",
724        area: "connectors",
725        // Read at each install, so a save applies on the next press.
726        class: EffectClass::Live,
727        rows: |config| {
728            vec![text(
729                "huggingface.token",
730                config.huggingface.token.clone().unwrap_or_else(|| "not set".to_owned()),
731            )]
732        },
733    },
734    ConfigKey {
735        path: "gotify.url",
736        area: "connectors",
737        class: EffectClass::Restart,
738        rows: |config| {
739            config.gotify.as_ref().map(|g| text("gotify.url", &g.url)).into_iter().collect()
740        },
741    },
742    ConfigKey {
743        path: "gotify.client_token",
744        area: "connectors",
745        class: EffectClass::Restart,
746        rows: |config| {
747            config
748                .gotify
749                .as_ref()
750                .map(|g| text("gotify.client_token", &g.client_token))
751                .into_iter()
752                .collect()
753        },
754    },
755    ConfigKey {
756        path: "slack[].workspace",
757        area: "connectors",
758        class: EffectClass::Restart,
759        rows: |config| {
760            config
761                .slack
762                .iter()
763                .map(|s| text(format!("slack.{}.workspace", s.workspace), &s.workspace))
764                .collect()
765        },
766    },
767    ConfigKey {
768        path: "slack[].token",
769        area: "connectors",
770        class: EffectClass::Restart,
771        rows: |config| {
772            config
773                .slack
774                .iter()
775                .map(|s| text(format!("slack.{}.token", s.workspace), &s.token))
776                .collect()
777        },
778    },
779    ConfigKey {
780        path: "slack[].poll_seconds",
781        area: "connectors",
782        class: EffectClass::Restart,
783        rows: |config| {
784            config
785                .slack
786                .iter()
787                .map(|s| number(format!("slack.{}.poll_seconds", s.workspace), s.poll_seconds))
788                .collect()
789        },
790    },
791    ConfigKey {
792        path: "slack[].dm_poll_seconds",
793        area: "connectors",
794        class: EffectClass::Restart,
795        rows: |config| {
796            config
797                .slack
798                .iter()
799                .map(|s| {
800                    number(format!("slack.{}.dm_poll_seconds", s.workspace), s.dm_poll_seconds)
801                })
802                .collect()
803        },
804    },
805    ConfigKey {
806        path: "slack[].thread_idle_days",
807        area: "connectors",
808        class: EffectClass::Live,
809        rows: |config| {
810            config
811                .slack
812                .iter()
813                .map(|s| {
814                    number(format!("slack.{}.thread_idle_days", s.workspace), s.thread_idle_days)
815                })
816                .collect()
817        },
818    },
819    ConfigKey {
820        path: "slack[].stale_after_hours",
821        area: "connectors",
822        class: EffectClass::Live,
823        rows: |config| {
824            config
825                .slack
826                .iter()
827                .map(|s| {
828                    number(format!("slack.{}.stale_after_hours", s.workspace), s.stale_after_hours)
829                })
830                .collect()
831        },
832    },
833    ConfigKey {
834        path: "systemone.enabled",
835        area: "connectors",
836        class: EffectClass::NextSession,
837        rows: |config| {
838            config.systemone.as_ref().map(|_| flag("systemone.enabled", true)).into_iter().collect()
839        },
840    },
841    ConfigKey {
842        path: "systemone.base_url",
843        area: "connectors",
844        class: EffectClass::NextSession,
845        rows: |config| {
846            config
847                .systemone
848                .as_ref()
849                .map(|s| text("systemone.base_url", &s.base_url))
850                .into_iter()
851                .collect()
852        },
853    },
854    ConfigKey {
855        path: "systemone.api_key",
856        area: "connectors",
857        class: EffectClass::NextSession,
858        rows: |config| {
859            config
860                .systemone
861                .as_ref()
862                .map(|s| {
863                    text("systemone.api_key", s.api_key.clone().unwrap_or_else(|| "not set".into()))
864                })
865                .into_iter()
866                .collect()
867        },
868    },
869    ConfigKey {
870        path: "systemone.model",
871        area: "connectors",
872        class: EffectClass::NextSession,
873        rows: |config| {
874            config
875                .systemone
876                .as_ref()
877                .map(|s| text("systemone.model", &s.model))
878                .into_iter()
879                .collect()
880        },
881    },
882    ConfigKey {
883        path: "systemone.timeout_ms",
884        area: "connectors",
885        class: EffectClass::NextSession,
886        rows: |config| {
887            config
888                .systemone
889                .as_ref()
890                .map(|s| number("systemone.timeout_ms", s.timeout_ms))
891                .into_iter()
892                .collect()
893        },
894    },
895    ConfigKey {
896        path: "vault.url",
897        area: "connectors",
898        class: EffectClass::Live,
899        rows: |config| {
900            vec![text_set("vault.url", config.vault.as_ref().map(|vault| vault.url.clone()))]
901        },
902    },
903    ConfigKey {
904        path: "vault.email",
905        area: "connectors",
906        class: EffectClass::Live,
907        rows: |config| {
908            vec![text_set("vault.email", config.vault.as_ref().map(|vault| vault.email.clone()))]
909        },
910    },
911    ConfigKey {
912        path: "vault.idle_minutes",
913        area: "connectors",
914        class: EffectClass::Live,
915        rows: |config| {
916            vec![number(
917                "vault.idle_minutes",
918                config.vault.as_ref().and_then(|vault| vault.idle_minutes),
919            )]
920        },
921    },
922    ConfigKey {
923        path: "plugins.auto_update",
924        area: "connectors",
925        class: EffectClass::NotReadYet,
926        rows: |config| vec![flag("plugins.auto_update", config.plugins.auto_update)],
927    },
928];
929
930const SERVER_KEYS: &[ConfigKey] = &[
931    ConfigKey {
932        path: "server.enabled",
933        area: "server",
934        class: EffectClass::Restart,
935        rows: |config| vec![flag("server.enabled", config.server.enabled)],
936    },
937    ConfigKey {
938        path: "server.port",
939        area: "server",
940        class: EffectClass::Restart,
941        rows: |config| vec![number("server.port", config.server.port)],
942    },
943    ConfigKey {
944        path: "server.bind",
945        area: "server",
946        class: EffectClass::Restart,
947        rows: |config| vec![text("server.bind", config.server.bind.to_string())],
948    },
949    ConfigKey {
950        path: "server.allowed_origins",
951        area: "server",
952        class: EffectClass::Live,
953        rows: |config| vec![list("server.allowed_origins", config.server.allowed_origins.clone())],
954    },
955    ConfigKey {
956        path: "server.first_frame_cap_kb",
957        area: "server",
958        class: EffectClass::Live,
959        rows: |config| vec![number("server.first_frame_cap_kb", config.server.first_frame_cap_kb)],
960    },
961    ConfigKey {
962        path: "server.more_page_cap_kb",
963        area: "server",
964        class: EffectClass::Live,
965        rows: |config| vec![number("server.more_page_cap_kb", config.server.more_page_cap_kb)],
966    },
967    ConfigKey {
968        path: "server.tool_record_cap_kb",
969        area: "server",
970        class: EffectClass::Live,
971        rows: |config| vec![number("server.tool_record_cap_kb", config.server.tool_record_cap_kb)],
972    },
973    ConfigKey {
974        path: "browser.fps",
975        area: "server",
976        class: EffectClass::NextSession,
977        rows: |config| vec![number("browser.fps", config.browser.fps)],
978    },
979    ConfigKey {
980        path: "browser.quality",
981        area: "server",
982        class: EffectClass::NextSession,
983        rows: |config| vec![number("browser.quality", config.browser.quality)],
984    },
985    ConfigKey {
986        path: "browser.idle_minutes",
987        area: "server",
988        class: EffectClass::Live,
989        rows: |config| vec![number("browser.idle_minutes", config.browser.idle_minutes)],
990    },
991];
992
993const TERMINAL_KEYS: &[ConfigKey] = &[
994    ConfigKey {
995        path: "terminal.shell",
996        area: "server",
997        class: EffectClass::NextSession,
998        rows: |config| vec![text("terminal.shell", &config.terminal.shell)],
999    },
1000    ConfigKey {
1001        path: "terminal.scrollback_lines",
1002        area: "server",
1003        class: EffectClass::NextSession,
1004        rows: |config| vec![number("terminal.scrollback_lines", config.terminal.scrollback_lines)],
1005    },
1006    ConfigKey {
1007        path: "terminal.idle_minutes",
1008        area: "server",
1009        class: EffectClass::Live,
1010        rows: |config| vec![number("terminal.idle_minutes", config.terminal.idle_minutes)],
1011    },
1012];
1013
1014fn enum_name<T: serde::Serialize>(value: T) -> String {
1015    serde_json::to_value(value).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or_default()
1016}
1017
1018const CLIENT_KEYS: &[ConfigKey] = &[
1019    ConfigKey {
1020        path: "client.mark",
1021        area: "client",
1022        class: EffectClass::Live,
1023        rows: |config| vec![text_set("client.mark", config.client.mark.clone())],
1024    },
1025    ConfigKey {
1026        path: "client.theme",
1027        area: "client",
1028        class: EffectClass::Live,
1029        rows: |config| vec![text_set("client.theme", config.client.theme.clone())],
1030    },
1031    ConfigKey {
1032        path: "client.font",
1033        area: "client",
1034        class: EffectClass::Live,
1035        rows: |config| vec![text_set("client.font", config.client.font.clone())],
1036    },
1037];
1038
1039const DICTATE_KEYS: &[ConfigKey] = &[
1040    ConfigKey {
1041        path: "dictate.enabled",
1042        area: "voice",
1043        class: EffectClass::Restart,
1044        rows: |config| vec![flag("dictate.enabled", config.dictate.enabled)],
1045    },
1046    ConfigKey {
1047        path: "dictate.models_dir",
1048        area: "voice",
1049        class: EffectClass::Restart,
1050        rows: |config| {
1051            vec![text(
1052                "dictate.models_dir",
1053                config.dictate.models_dir.clone().unwrap_or_else(|| "not set".to_owned()),
1054            )]
1055        },
1056    },
1057    ConfigKey {
1058        path: "dictate.transcribe_model",
1059        area: "voice",
1060        class: EffectClass::Restart,
1061        rows: |config| {
1062            vec![text(
1063                "dictate.transcribe_model",
1064                config.dictate.transcribe_model.clone().unwrap_or_else(|| "not set".to_owned()),
1065            )]
1066        },
1067    },
1068    ConfigKey {
1069        path: "dictate.cleanup_model",
1070        area: "voice",
1071        class: EffectClass::Restart,
1072        rows: |config| {
1073            vec![text(
1074                "dictate.cleanup_model",
1075                config.dictate.cleanup_model.clone().unwrap_or_else(|| "not set".to_owned()),
1076            )]
1077        },
1078    },
1079    ConfigKey {
1080        path: "dictate.language",
1081        area: "voice",
1082        class: EffectClass::Restart,
1083        rows: |config| {
1084            vec![text(
1085                "dictate.language",
1086                config.dictate.language.clone().unwrap_or_else(|| "autodetect".to_owned()),
1087            )]
1088        },
1089    },
1090    ConfigKey {
1091        path: "dictate.normalizer",
1092        area: "voice",
1093        class: EffectClass::Restart,
1094        rows: |config| vec![flag("dictate.normalizer", config.dictate.normalizer)],
1095    },
1096    ConfigKey {
1097        path: "dictate.max_capture_minutes",
1098        area: "voice",
1099        class: EffectClass::Restart,
1100        rows: |config| {
1101            vec![number("dictate.max_capture_minutes", config.dictate.max_capture_minutes)]
1102        },
1103    },
1104    ConfigKey {
1105        path: "dictate.bind",
1106        area: "voice",
1107        class: EffectClass::Live,
1108        rows: |config| vec![text("dictate.bind", config.dictate.bind.label())],
1109    },
1110    ConfigKey {
1111        path: "dictate.mode",
1112        area: "voice",
1113        class: EffectClass::Live,
1114        rows: |config| vec![text("dictate.mode", config.dictate.mode.label())],
1115    },
1116    ConfigKey {
1117        path: "dictate.styling",
1118        area: "voice",
1119        class: EffectClass::NextSession,
1120        rows: |config| {
1121            vec![text(
1122                "dictate.styling",
1123                config.dictate.styling.map_or_else(
1124                    || enum_name(forge_dictate::normalize::Styling::default()),
1125                    enum_name,
1126                ),
1127            )]
1128        },
1129    },
1130    ConfigKey {
1131        path: "dictate.structure",
1132        area: "voice",
1133        class: EffectClass::NextSession,
1134        rows: |config| {
1135            vec![text(
1136                "dictate.structure",
1137                config.dictate.structure.map_or_else(
1138                    || enum_name(forge_dictate::normalize::Structure::default()),
1139                    enum_name,
1140                ),
1141            )]
1142        },
1143    },
1144    ConfigKey {
1145        path: "dictate.context",
1146        area: "voice",
1147        class: EffectClass::NextSession,
1148        rows: |config| {
1149            vec![text(
1150                "dictate.context",
1151                config.dictate.context.map_or_else(
1152                    || enum_name(forge_dictate::normalize::Context::default()),
1153                    enum_name,
1154                ),
1155            )]
1156        },
1157    },
1158];
1159
1160/// The speaking role's keys: the other direction of the same voice area.
1161const TTS_KEYS: &[ConfigKey] = &[
1162    ConfigKey {
1163        path: "tts.enabled",
1164        area: "voice",
1165        class: EffectClass::Restart,
1166        rows: |config| vec![flag("tts.enabled", config.tts.enabled)],
1167    },
1168    ConfigKey {
1169        path: "tts.models_dir",
1170        area: "voice",
1171        class: EffectClass::Restart,
1172        rows: |config| {
1173            vec![text(
1174                "tts.models_dir",
1175                config.tts.models_dir.clone().unwrap_or_else(|| "not set".to_owned()),
1176            )]
1177        },
1178    },
1179    ConfigKey {
1180        path: "tts.speaking_model",
1181        area: "voice",
1182        class: EffectClass::Restart,
1183        rows: |config| {
1184            vec![text(
1185                "tts.speaking_model",
1186                config
1187                    .tts
1188                    .speaking_model
1189                    .clone()
1190                    .unwrap_or_else(|| crate::tts::SPEAKING_MODEL.to_owned()),
1191            )]
1192        },
1193    },
1194    ConfigKey {
1195        path: "tts.voice",
1196        area: "voice",
1197        class: EffectClass::Restart,
1198        rows: |config| {
1199            vec![text(
1200                "tts.voice",
1201                config.tts.voice.clone().unwrap_or_else(|| forge_tts::DEFAULT_VOICE.to_owned()),
1202            )]
1203        },
1204    },
1205    ConfigKey {
1206        path: "tts.max_utterance_seconds",
1207        area: "voice",
1208        class: EffectClass::Restart,
1209        rows: |config| vec![number("tts.max_utterance_seconds", config.tts.max_utterance_seconds)],
1210    },
1211];
1212
1213const MODEL_KEYS: &[ConfigKey] = &[ConfigKey {
1214    path: "models.*.auto_compact_window",
1215    area: "models",
1216    class: EffectClass::NextSession,
1217    rows: |config| {
1218        let mut names: Vec<&String> = config.models.keys().collect();
1219        names.sort_unstable();
1220        names
1221            .into_iter()
1222            .map(|name| {
1223                let path = format!("models.{name}.auto_compact_window");
1224                match config.models[name].auto_compact_window {
1225                    Some(window) => number(path, window),
1226                    None => text(path, "not set"),
1227                }
1228            })
1229            .collect()
1230    },
1231}];
1232
1233const SESSION_KEYS: &[ConfigKey] = &[
1234    ConfigKey {
1235        path: "sessions.heal",
1236        area: "sessions",
1237        class: EffectClass::Live,
1238        rows: |config| vec![flag("sessions.heal", config.sessions.heal)],
1239    },
1240    ConfigKey {
1241        path: "sessions.heal_attempts",
1242        area: "sessions",
1243        class: EffectClass::Live,
1244        rows: |config| vec![number("sessions.heal_attempts", config.sessions.heal_attempts)],
1245    },
1246    ConfigKey {
1247        path: "sessions.heal_backoff",
1248        area: "sessions",
1249        class: EffectClass::Live,
1250        rows: |config| {
1251            vec![list(
1252                "sessions.heal_backoff",
1253                config.sessions.heal_backoff.iter().copied().map(format_session_duration).collect(),
1254            )]
1255        },
1256    },
1257    ConfigKey {
1258        path: "sessions.heal_stable",
1259        area: "sessions",
1260        class: EffectClass::Live,
1261        rows: |config| {
1262            vec![text("sessions.heal_stable", format_session_duration(config.sessions.heal_stable))]
1263        },
1264    },
1265];
1266
1267const HANDOFF_KEYS: &[ConfigKey] = &[
1268    ConfigKey {
1269        path: "handoff.enabled",
1270        area: "handoff",
1271        class: EffectClass::Live,
1272        rows: |config| vec![flag("handoff.enabled", config.handoff.enabled.unwrap_or(true))],
1273    },
1274    ConfigKey {
1275        path: "handoff.at_percent",
1276        area: "handoff",
1277        class: EffectClass::Live,
1278        rows: |config| {
1279            vec![number(
1280                "handoff.at_percent",
1281                config.handoff.at_percent.unwrap_or(u32::from(DEFAULT_HANDOFF_AT_PERCENT)),
1282            )]
1283        },
1284    },
1285    ConfigKey {
1286        path: "handoff.after_compactions",
1287        area: "handoff",
1288        class: EffectClass::Live,
1289        rows: |config| {
1290            vec![number(
1291                "handoff.after_compactions",
1292                config.handoff.after_compactions.unwrap_or(DEFAULT_HANDOFF_AFTER_COMPACTIONS),
1293            )]
1294        },
1295    },
1296];
1297
1298const DOCUMENT_KEYS: &[ConfigKey] = &[ConfigKey {
1299    path: "documents.max_size_mib",
1300    area: "documents",
1301    class: EffectClass::Live,
1302    rows: |config| {
1303        vec![number("documents.max_size_mib", config.document_max_bytes / (1024 * 1024))]
1304    },
1305}];
1306
1307const USAGE_KEYS: &[ConfigKey] = &[ConfigKey {
1308    path: "usage.poll_seconds",
1309    area: "usage",
1310    class: EffectClass::Live,
1311    rows: |config| vec![number("usage.poll_seconds", config.usage_poll_interval.as_secs())],
1312}];
1313
1314const GATEWAY_KEYS: &[ConfigKey] = &[
1315    ConfigKey {
1316        path: "gateway.port",
1317        area: "gateway",
1318        class: EffectClass::Restart,
1319        rows: |config| vec![number("gateway.port", config.gateway_port)],
1320    },
1321    ConfigKey {
1322        path: "gateway.streak_count",
1323        area: "gateway",
1324        class: EffectClass::Live,
1325        rows: |config| vec![number("gateway.streak_count", config.gateway_rotation.streak_count)],
1326    },
1327    ConfigKey {
1328        path: "gateway.streak_window_secs",
1329        area: "gateway",
1330        class: EffectClass::Live,
1331        rows: |config| {
1332            vec![number(
1333                "gateway.streak_window_secs",
1334                config.gateway_rotation.streak_window.as_secs(),
1335            )]
1336        },
1337    },
1338    ConfigKey {
1339        path: "gateway.no_reset_cooldown_secs",
1340        area: "gateway",
1341        class: EffectClass::Live,
1342        rows: |config| {
1343            vec![number(
1344                "gateway.no_reset_cooldown_secs",
1345                config.gateway_rotation.no_reset_cooldown.as_secs(),
1346            )]
1347        },
1348    },
1349];
1350
1351const ENV_KEYS: &[ConfigKey] = &[ConfigKey {
1352    path: "env.*",
1353    area: "env",
1354    class: EffectClass::NextSession,
1355    rows: |config| {
1356        sorted_keys(&config.global_env)
1357            .into_iter()
1358            .map(|name| text(format!("env.{name}"), &config.global_env[name]))
1359            .collect()
1360    },
1361}];
1362
1363/// The instance names and the key's own name for one row.
1364///
1365/// A name can contain dots - an account display name, an org, a project, a
1366/// model slug, an env var - so the split cannot be done on separators
1367/// alone. The config's own instance names say where a name ends, and the
1368/// catalogue's table segments say where a repeated key's own name begins.
1369fn split_row(area: &str, path: &str, config: &LoadedConfig) -> (Vec<String>, String) {
1370    match area {
1371        "accounts" => {
1372            if let Some((account, tail)) =
1373                instance(path, "accounts.", config.accounts.iter().map(|a| a.display_name.as_str()))
1374            {
1375                return (
1376                    vec![account.to_owned()],
1377                    key_leaf(tail, &["model_slugs", "model_aliases", "env"]),
1378                );
1379            }
1380        }
1381        "projects" => {
1382            let best = config
1383                .projects
1384                .iter()
1385                .filter_map(|project| {
1386                    let prefix = format!("orgs.{}.projects.{}.", project.org, project.name);
1387                    path.strip_prefix(&prefix).map(|tail| (project, tail))
1388                })
1389                .max_by_key(|(project, _)| project.org.len() + project.name.len());
1390            if let Some((project, tail)) = best {
1391                return (
1392                    vec![project.org.clone(), project.name.clone()],
1393                    key_leaf(tail, &["env", "handoff", "slack"]),
1394                );
1395            }
1396            // The org's own rows (`orgs[].name`, `accounts`, `fallback_accounts`,
1397            // `browser_profile`, `slack.workspace`) carry no project segment;
1398            // they group by the org so two orgs do not interleave.
1399            if let Some((org, tail)) =
1400                instance(path, "orgs.", config.projects.iter().map(|p| p.org.as_str()))
1401            {
1402                return (vec![org.to_owned()], key_leaf(tail, &["slack"]));
1403            }
1404        }
1405        "connectors" => {
1406            if let Some((workspace, tail)) =
1407                instance(path, "slack.", config.slack.iter().map(|s| s.workspace.as_str()))
1408            {
1409                return (vec!["slack".to_owned(), workspace.to_owned()], tail.to_owned());
1410            }
1411            if let Some((table, tail)) = path.split_once('.') {
1412                return (vec![table.to_owned()], tail.to_owned());
1413            }
1414        }
1415        "models" => {
1416            if let Some((name, tail)) =
1417                instance(path, "models.", config.models.keys().map(String::as_str))
1418            {
1419                return (vec![name.to_owned()], tail.to_owned());
1420            }
1421        }
1422        // The two directions of one conversation, each in its own box: the
1423        // table a key belongs to is its group, as it is for `[server]`.
1424        "server" | "voice" => {
1425            if let Some((table, tail)) = path.split_once('.') {
1426                return (vec![table.to_owned()], tail.to_owned());
1427            }
1428        }
1429        "env" => {
1430            if let Some(rest) = path.strip_prefix("env.") {
1431                return (Vec::new(), rest.to_owned());
1432            }
1433        }
1434        _ => {}
1435    }
1436    (Vec::new(), last_segment(path))
1437}
1438
1439/// The longest of `names` the path names after `prefix`, and the key tail
1440/// that follows it.
1441///
1442/// The longest match is what keeps a name that is a prefix of another
1443/// (`Beta` beside `Beta.Two`) on its own rows. The corner it cannot
1444/// resolve is a name that itself ends in a key-looking segment: with `a`
1445/// beside `a.env`, the row `accounts.a.env.FLAG` matches the longer name
1446/// and is grouped under `a.env`. Telling those apart needs the catalogue's
1447/// key set, which a row path does not carry.
1448fn instance<'a, 'b>(
1449    path: &'b str,
1450    prefix: &str,
1451    names: impl Iterator<Item = &'a str>,
1452) -> Option<(&'a str, &'b str)> {
1453    let rest = path.strip_prefix(prefix)?;
1454    names
1455        .filter_map(|name| {
1456            rest.strip_prefix(name).and_then(|tail| tail.strip_prefix('.').map(|tail| (name, tail)))
1457        })
1458        .max_by_key(|(name, _)| name.len())
1459}
1460
1461/// The key's own name from a tail that may start with a repeated table:
1462/// `model_slugs.glm-5.3-flash` names the slug, not the table.
1463fn key_leaf(tail: &str, tables: &[&str]) -> String {
1464    for table in tables {
1465        if let Some(rest) = tail.strip_prefix(table).and_then(|rest| rest.strip_prefix('.')) {
1466            return rest.to_owned();
1467        }
1468    }
1469    last_segment(tail)
1470}
1471
1472fn last_segment(path: &str) -> String {
1473    path.rsplit('.').next().unwrap_or(path).to_owned()
1474}
1475
1476/// The whole document for one load, every catalogued key resolved.
1477pub(crate) fn document(config: &LoadedConfig, version: &str, source: &Path) -> SettingsDocument {
1478    let areas = AREAS
1479        .iter()
1480        .map(|area| SettingsArea {
1481            id: area.id.to_owned(),
1482            name: area.name.to_owned(),
1483            note: area.note.to_owned(),
1484            keys: all_keys()
1485                .filter(|key| key.area == area.id)
1486                .flat_map(|key| {
1487                    (key.rows)(config).into_iter().map(|row| {
1488                        let (group, leaf) = split_row(area.id, &row.path, config);
1489                        SettingsKey {
1490                            path: row.path,
1491                            leaf,
1492                            group,
1493                            pattern: key.path.to_owned(),
1494                            kind: row.kind,
1495                            value: row.value,
1496                            class: key.class,
1497                            edit: edit_wire(edit_spec(key.path)),
1498                        }
1499                    })
1500                })
1501                .collect(),
1502        })
1503        .collect();
1504    SettingsDocument {
1505        version: version.to_owned(),
1506        source: source.display().to_string(),
1507        fingerprint: config.raw_fingerprint.clone(),
1508        totals: SettingsTotals { keys: 0, areas: 0 },
1509        areas,
1510    }
1511    .with_totals()
1512}
1513
1514/// One key a settings tool can address: the document row the writer would
1515/// locate, with everything an edit needs to be built and validated.
1516pub(crate) struct SettingsTarget {
1517    pub path: String,
1518    pub pattern: String,
1519    pub area: String,
1520    pub class: EffectClass,
1521    pub edit: SettingsEdit,
1522    pub current: serde_json::Value,
1523}
1524
1525/// The document row a concrete path names, or `None` when the path is no
1526/// key this load resolves. `settings__get` and `settings__set` take a path
1527/// exactly as the row states it - never a catalogue pattern, which has no
1528/// value and no instance to write.
1529pub(crate) fn target(document: &SettingsDocument, path: &str) -> Option<SettingsTarget> {
1530    for area in &document.areas {
1531        if let Some(key) = area.keys.iter().find(|key| key.path == path) {
1532            return Some(SettingsTarget {
1533                path: key.path.clone(),
1534                pattern: key.pattern.clone(),
1535                area: area.id.clone(),
1536                class: key.class,
1537                edit: key.edit.clone(),
1538                current: key.value.clone(),
1539            });
1540        }
1541    }
1542    None
1543}
1544
1545/// One catalogue pattern's own metadata, for a key the config does not set
1546/// and therefore emits no row for.
1547pub(crate) struct CatalogueKeyTarget {
1548    pub class: EffectClass,
1549    pub edit: SettingsEdit,
1550}
1551
1552/// The catalogue entry a pattern names, or `None` when it is not a key the
1553/// loader accepts.
1554pub(crate) fn catalogue_target(pattern: &str) -> Option<CatalogueKeyTarget> {
1555    all_keys()
1556        .find(|key| key.path == pattern)
1557        .map(|key| CatalogueKeyTarget { class: key.class, edit: edit_wire(edit_spec(key.path)) })
1558}
1559
1560/// The row a writer locates for a key the document does not carry: its
1561/// pattern and path are the edit's own, and only the fields `set_key` reads
1562/// are populated.
1563pub(crate) fn synthetic_key(path: &str, pattern: &str) -> Option<SettingsKey> {
1564    let catalogue = catalogue_target(pattern)?;
1565    Some(SettingsKey {
1566        path: path.to_owned(),
1567        leaf: path.rsplit('.').next().unwrap_or(path).to_owned(),
1568        group: Vec::new(),
1569        pattern: pattern.to_owned(),
1570        // `set_key` validates and locates from `edit`, `pattern` and `path`;
1571        // these describe a document row it does not have.
1572        kind: SettingKind::String,
1573        value: serde_json::Value::Null,
1574        class: catalogue.class,
1575        edit: catalogue.edit,
1576    })
1577}
1578
1579/// The target a caller's path names for a read or a write.
1580///
1581/// A configured key is the document's own row. **A catalogue key the config
1582/// does not set emits no row at all** - an optional `browser_profile`, an
1583/// `env_file`, a handoff policy - so the path is matched back to its
1584/// catalogue pattern here: the key is addressable with `current` null, and
1585/// the writer creates it on the write. The instances a pattern takes must
1586/// still exist, so a mistyped account, org, project or workspace resolves to
1587/// nothing rather than to a key nothing owns.
1588pub(crate) fn resolve_target(
1589    config: &LoadedConfig,
1590    document: &SettingsDocument,
1591    path: &str,
1592) -> Option<SettingsTarget> {
1593    if let Some(row) = target(document, path) {
1594        return Some(row);
1595    }
1596    let mut best: Option<(usize, SettingsTarget)> = None;
1597    for key in all_keys() {
1598        let Some(names) = crate::settings_write::match_pattern(key.path, path) else { continue };
1599        if !instances_exist(config, key.path, &names) {
1600            continue;
1601        }
1602        // A dotted instance name can let a broader pattern match the same
1603        // path; the pattern with more literal segments is the specific one.
1604        let literals = key
1605            .path
1606            .split('.')
1607            .filter(|segment| *segment != "*" && !segment.ends_with("[]"))
1608            .count();
1609        let found = SettingsTarget {
1610            path: path.to_owned(),
1611            pattern: key.path.to_owned(),
1612            area: key.area.to_owned(),
1613            class: key.class,
1614            edit: edit_wire(edit_spec(key.path)),
1615            current: serde_json::Value::Null,
1616        };
1617        if best.as_ref().is_none_or(|(best_literals, _)| literals > *best_literals) {
1618            best = Some((literals, found));
1619        }
1620    }
1621    best.map(|(_, found)| found)
1622}
1623
1624/// Whether the instance names a pattern took all exist in `config`.
1625fn instances_exist(config: &LoadedConfig, pattern: &str, names: &[String]) -> bool {
1626    if pattern.starts_with("orgs[].projects[].") {
1627        let (Some(org), Some(project)) = (names.first(), names.get(1)) else { return false };
1628        return config.projects.iter().any(|held| &held.org == org && &held.name == project);
1629    }
1630    if pattern.starts_with("orgs[].") {
1631        let Some(org) = names.first() else { return false };
1632        return config.projects.iter().any(|held| &held.org == org);
1633    }
1634    if pattern.starts_with("accounts[].") {
1635        let Some(account) = names.first() else { return false };
1636        return config.accounts.iter().any(|held| &held.display_name == account);
1637    }
1638    if pattern.starts_with("slack[].") {
1639        let Some(workspace) = names.first() else { return false };
1640        return config.slack.iter().any(|held| &held.workspace == workspace);
1641    }
1642    // `models.*` and the global tables have no entry list to check against:
1643    // setting a model's key creates its table, which the loader accepts.
1644    true
1645}
1646
1647/// The project a concrete path belongs to, when the path is one of a
1648/// project's keys. The config's own names are matched, longest first, so a
1649/// project name containing a dot stays one name.
1650///
1651/// Org-level keys are [`owning_org`]'s, and global keys belong to neither.
1652pub(crate) fn owning_project<'a>(
1653    config: &'a LoadedConfig,
1654    path: &str,
1655) -> Option<&'a LoadedProject> {
1656    config
1657        .projects
1658        .iter()
1659        .filter(|project| {
1660            path.starts_with(&format!("orgs.{}.projects.{}.", project.org, project.name))
1661        })
1662        .max_by_key(|project| project.org.len() + project.name.len())
1663}
1664
1665/// The org a concrete path belongs to, when it is one of the org's own keys
1666/// (`orgs.<org>.browser_profile`, the org's Slack binding) rather than a
1667/// project's. A read from another org is refused by name like another
1668/// project's key is: the org's settings shape every project in it, and the
1669/// org segment is ownership even though the plan's sentence names projects.
1670pub(crate) fn owning_org<'a>(config: &'a LoadedConfig, path: &str) -> Option<&'a str> {
1671    org_names(config)
1672        .into_iter()
1673        .filter(|org| path.starts_with(&format!("orgs.{org}.")))
1674        .max_by_key(|org| org.len())
1675        .filter(|org| {
1676            let rest = &path[format!("orgs.{org}.").len()..];
1677            !rest.starts_with("projects.")
1678        })
1679}
1680
1681/// The name of a value's edit control, as the index states its type.
1682fn edit_kind_name(kind: forge_primitives::EditKind) -> &'static str {
1683    match kind {
1684        forge_primitives::EditKind::String => "string",
1685        forge_primitives::EditKind::Number => "number",
1686        forge_primitives::EditKind::Bool => "bool",
1687        forge_primitives::EditKind::Enum => "enum",
1688        forge_primitives::EditKind::List => "list",
1689    }
1690}
1691
1692/// The instance names a repeated catalogue key takes, for the caller's own
1693/// reach: its project's keys list its project alone, and the global
1694/// accounts, workspaces and models list every one this config declares.
1695fn index_instances(config: &LoadedConfig, pattern: &str, org: &str, project: &str) -> Vec<String> {
1696    if pattern.starts_with("accounts[]") {
1697        return config.accounts.iter().map(|account| account.display_name.clone()).collect();
1698    }
1699    if pattern.starts_with("orgs[].projects[]") {
1700        return config
1701            .projects
1702            .iter()
1703            .filter(|held| held.org == org && held.name == project)
1704            .map(|held| format!("{}/{}", held.org, held.name))
1705            .collect();
1706    }
1707    if pattern.starts_with("orgs[]") {
1708        if config.projects.iter().any(|held| held.org == org) {
1709            return vec![org.to_owned()];
1710        }
1711        return Vec::new();
1712    }
1713    if pattern.starts_with("slack[]") {
1714        return config.slack.iter().map(|workspace| workspace.workspace.clone()).collect();
1715    }
1716    if pattern.starts_with("models.*") {
1717        let mut names: Vec<String> = config.models.keys().cloned().collect();
1718        names.sort_unstable();
1719        return names;
1720    }
1721    Vec::new()
1722}
1723
1724/// Whether a row's value is one the reader would act on. The resolvers draw
1725/// "not set" for an unset optional that has no row of its own (a model's
1726/// window), so that placeholder is not a setting either.
1727fn index_value_set(value: &serde_json::Value) -> bool {
1728    !value.is_null() && value.as_str() != Some("not set")
1729}
1730
1731/// Whether a row is one the caller's reach covers: its own project's rows
1732/// and its own org's, so another project's set key does not mark the
1733/// caller's index as set.
1734fn index_row_in_reach(row: &SettingsKey, pattern: &str, org: &str, project: &str) -> bool {
1735    if pattern.starts_with("orgs[].projects[]") {
1736        row.group.first().is_some_and(|held| held == org)
1737            && row.group.get(1).is_some_and(|held| held == project)
1738    } else if pattern.starts_with("orgs[]") {
1739        row.group.first().is_some_and(|held| held == org)
1740    } else {
1741        true
1742    }
1743}
1744
1745/// The compact catalogue an agent reads: sections and keys with type, whether
1746/// the config sets one, and when an edit lands. No value crosses, so this is
1747/// the shape of the file without its contents.
1748pub(crate) fn index_text(
1749    config: &LoadedConfig,
1750    document: &SettingsDocument,
1751    org: &str,
1752    project: &str,
1753) -> String {
1754    use std::fmt::Write as _;
1755
1756    let rows: Vec<&SettingsKey> = document.areas.iter().flat_map(|area| &area.keys).collect();
1757    let mut out = String::from(
1758        "forge's configuration, one line per catalogue key:\n\
1759         key  type  state  effect  [instances]\n\
1760         `unset` means this config resolves no value for it; a key with a built-in shows set.\n",
1761    );
1762    let _ = writeln!(out, "your project: {org}/{project}");
1763    for area in AREAS {
1764        out.push('\n');
1765        out.push_str(area.id);
1766        out.push('\n');
1767        for key in all_keys().filter(|key| key.area == area.id) {
1768            let set = rows.iter().any(|row| {
1769                row.pattern == key.path
1770                    && index_value_set(&row.value)
1771                    && index_row_in_reach(row, key.path, org, project)
1772            });
1773            let instances = index_instances(config, key.path, org, project);
1774            let pattern = key.path;
1775            let _ = write!(
1776                out,
1777                "  {}  {}  {}  {}",
1778                pattern,
1779                edit_kind_name(edit_spec(pattern).kind),
1780                if set { "set" } else { "unset" },
1781                key.class.as_str(),
1782            );
1783            if !instances.is_empty() {
1784                let _ = write!(out, "  [{}]", instances.join(", "));
1785            }
1786            out.push('\n');
1787        }
1788    }
1789    out
1790}
1791
1792/// The reload summary, derived from the same rows the page draws: a key
1793/// reported as moved and the class the page chips it with are one table's.
1794pub(crate) fn diff(previous: &LoadedConfig, next: &LoadedConfig) -> ConfigReload {
1795    let mut reload = ConfigReload::default();
1796    for key in all_keys() {
1797        let old = (key.rows)(previous);
1798        let new = (key.rows)(next);
1799        for path in changed_paths(&old, &new) {
1800            match key.class {
1801                EffectClass::Restart => reload.restart_only.push(path),
1802                // Nothing reads it, so a change applies nothing and is not
1803                // reported as applied. The page still draws the new value.
1804                EffectClass::NotReadYet => {}
1805                EffectClass::Live | EffectClass::NextSession => reload.changed.push(path),
1806            }
1807        }
1808    }
1809    reload
1810}
1811
1812fn changed_paths(old: &[KeyRow], new: &[KeyRow]) -> Vec<String> {
1813    let old_by_path: std::collections::HashMap<&str, &KeyRow> =
1814        old.iter().map(|row| (row.path.as_str(), row)).collect();
1815    let new_by_path: std::collections::HashMap<&str, &KeyRow> =
1816        new.iter().map(|row| (row.path.as_str(), row)).collect();
1817    let mut changed = Vec::new();
1818    for row in new {
1819        if old_by_path.get(row.path.as_str()) != Some(&row) {
1820            changed.push(row.path.clone());
1821        }
1822    }
1823    for row in old {
1824        if !new_by_path.contains_key(row.path.as_str()) {
1825            changed.push(row.path.clone());
1826        }
1827    }
1828    changed
1829}
1830
1831#[cfg(test)]
1832mod tests {
1833    use std::path::PathBuf;
1834
1835    use super::*;
1836
1837    /// A config setting every key the loader accepts, so the catalogue can
1838    /// be walked against the loader itself rather than against a list that
1839    /// moves with it. A key added to a struct without a row here is what the
1840    /// completeness test below is for.
1841    const EVERY_KEY: &str = r#"
1842[env]
1843GLOBAL_FLAG = "1"
1844
1845[[accounts]]
1846display_name = "Primary"
1847provider = "openrouter"
1848base_url = "https://openrouter.ai/api"
1849token = "sk-settings-index-sentinel"
1850models = ["glm-5.3-flash"]
1851model_slugs = { "glm-5.3-flash" = "z-ai/glm-5.3-flash" }
1852model_aliases = { "glm-5.3-flash" = ["glm-flash"] }
1853
1854[accounts.env]
1855PROVIDER_FLAG = "1"
1856
1857[[orgs]]
1858name = "TestOrg"
1859accounts = ["Primary"]
1860fallback_accounts = ["Primary"]
1861browser_profile = "work"
1862
1863[orgs.slack]
1864workspace = "Test Slack"
1865
1866[[orgs.projects]]
1867name = "proj"
1868path = "/tmp/proj"
1869model = "glm-5.3-flash"
1870permission_mode = "auto"
1871max_workers = 3
1872env_file = "/tmp/forge-settings-test.env"
1873document_roots = ["/tmp"]
1874browser_profile = "projprofile"
1875
1876[orgs.projects.env]
1877PROJECT_FLAG = "1"
1878
1879[orgs.projects.handoff]
1880enabled = false
1881at_percent = 70
1882after_compactions = 2
1883
1884[orgs.projects.slack]
1885workspace = "Test Slack"
1886
1887[models."glm-5.3-flash"]
1888auto_compact_window = 1024
1889
1890[handoff]
1891enabled = true
1892at_percent = 80
1893after_compactions = 3
1894
1895[dictate]
1896enabled = true
1897models_dir = "/tmp/models"
1898transcribe_model = "m1"
1899cleanup_model = "m2"
1900language = "en"
1901normalizer = false
1902max_capture_minutes = 10
1903bind = "left_cmd"
1904mode = "hold"
1905styling = "casual"
1906structure = "lists"
1907context = "email"
1908
1909[tts]
1910enabled = true
1911models_dir = "/tmp/tts-models"
1912speaking_model = "hexgrad/Kokoro-82M"
1913voice = "af_heart"
1914max_utterance_seconds = 60
1915
1916[huggingface]
1917token = "hf_test"
1918
1919[gotify]
1920url = "https://gotify.test"
1921client_token = "g"
1922
1923[systemone]
1924enabled = true
1925base_url = "https://systemone.test"
1926api_key = "k"
1927model = "jev"
1928timeout_ms = 1000
1929
1930[vault]
1931url = "https://vault.test"
1932email = "hi@vedhavyas.dev"
1933idle_minutes = 30
1934
1935[[slack]]
1936workspace = "Test Slack"
1937token = "x"
1938poll_seconds = 10
1939dm_poll_seconds = 60
1940thread_idle_days = 3
1941stale_after_hours = 6
1942
1943[plugins]
1944auto_update = true
1945
1946[gateway]
1947port = 8787
1948streak_count = 3
1949streak_window_secs = 30
1950no_reset_cooldown_secs = 20
1951
1952[server]
1953enabled = true
1954port = 8790
1955bind = "127.0.0.1"
1956first_frame_cap_kb = 64
1957more_page_cap_kb = 64
1958tool_record_cap_kb = 8
1959allowed_origins = ["http://localhost:8080"]
1960
1961[client]
1962mark = "panes"
1963theme = "dark"
1964font = "system"
1965
1966[browser]
1967fps = 10
1968quality = 50
1969idle_minutes = 5
1970
1971[terminal]
1972shell = "/bin/zsh"
1973scrollback_lines = 1000
1974idle_minutes = 15
1975
1976[documents]
1977max_size_mib = 5
1978
1979[usage]
1980poll_seconds = 5
1981
1982[sessions]
1983heal = false
1984heal_attempts = 2
1985heal_backoff = ["1s", "2s"]
1986heal_stable = "1m"
1987"#;
1988
1989    /// Two orgs, one with dotted names, for the org-level rows and the
1990    /// dotted instances the split must keep whole.
1991    const TWO_ORGS: &str = r#"
1992[[accounts]]
1993display_name = "Acme.Corp"
1994provider = "anthropic"
1995token = "t"
1996models = ["claude-sonnet-5"]
1997
1998[[orgs]]
1999name = "Acme.Corp"
2000accounts = ["Acme.Corp"]
2001
2002[orgs.slack]
2003workspace = "Acme Workspace"
2004
2005[[orgs.projects]]
2006name = "api.v2"
2007path = "/tmp/api"
2008
2009[orgs.projects.slack]
2010workspace = "Acme Workspace"
2011
2012[[orgs]]
2013name = "Beta"
2014accounts = ["Acme.Corp"]
2015
2016[[orgs.projects]]
2017name = "web"
2018path = "/tmp/web"
2019
2020[[slack]]
2021workspace = "Acme Workspace"
2022token = "x"
2023poll_seconds = 10
2024dm_poll_seconds = 60
2025thread_idle_days = 3
2026stale_after_hours = 6
2027"#;
2028
2029    fn load(raw: &str) -> LoadedConfig {
2030        let dir = tempfile::tempdir().expect("tempdir");
2031        let forge = dir.path().join("forge");
2032        std::fs::create_dir_all(&forge).expect("forge dir");
2033        std::fs::write(forge.join("forge.toml"), raw).expect("write config");
2034        crate::config::load_from_dir(dir.path()).expect("the fixture loads")
2035    }
2036
2037    /// The load refusal a probe config produces, without the tempdir naming
2038    /// anything the test reads back.
2039    fn load_error(raw: &str) -> crate::error::WorkspaceError {
2040        let dir = tempfile::tempdir().expect("tempdir");
2041        let forge = dir.path().join("forge");
2042        std::fs::create_dir_all(&forge).expect("forge dir");
2043        std::fs::write(forge.join("forge.toml"), raw).expect("write config");
2044        crate::config::load_from_dir(dir.path()).expect_err("the stray key is refused")
2045    }
2046
2047    /// Every key the loader accepts, as the fixture's own leaf paths, is in
2048    /// the catalogue, and every catalogue entry is a key the fixture sets.
2049    /// A key accepted by a struct but missing here is the drift this catches
2050    /// only if the fixture moves with the struct; the round trip is what
2051    /// proves each catalogued path is really accepted.
2052    #[test]
2053    fn the_catalogue_is_the_loaders_key_set() {
2054        let value: toml::Value = toml::from_str(EVERY_KEY).expect("the fixture parses");
2055        let mut leaves = Vec::new();
2056        collect_leaves("", &value, &mut leaves);
2057        let fixture: BTreeSet<String> = leaves.iter().map(|path| normalize_path(path)).collect();
2058        let catalogue: BTreeSet<String> = all_keys().map(|key| key.path.to_owned()).collect();
2059        assert_eq!(
2060            fixture, catalogue,
2061            "a key the fixture sets has no catalogue row, or the catalogue names a key the \
2062             loader does not accept",
2063        );
2064    }
2065
2066    /// The other direction, from the structs themselves: a stray key makes
2067    /// serde name every field it would accept, so a field added to a struct
2068    /// reaches this test without anyone remembering to add it to a list.
2069    #[test]
2070    fn the_catalogue_covers_every_field_the_loader_accepts() {
2071        const BASE: &str = r#"
2072[[orgs]]
2073name = "O"
2074accounts = ["A"]
2075
2076[[orgs.projects]]
2077name = "P"
2078path = "/tmp/p"
2079
2080[[accounts]]
2081display_name = "A"
2082provider = "anthropic"
2083token = "t"
2084models = ["m"]
2085"#;
2086        let top = accepted_fields(&format!("zzz_unknown = 1\n{BASE}"));
2087        let accounts = accepted_fields(&format!(
2088            "{BASE}\n[[accounts]]\nzzz_unknown = 1\ndisplay_name = \"B\"\nprovider = \
2089             \"anthropic\"\ntoken = \"t\"\nmodels = [\"m\"]\n"
2090        ));
2091        let orgs = accepted_fields(&format!(
2092            "{BASE}\n[[orgs]]\nzzz_unknown = 1\nname = \"O2\"\naccounts = [\"A\"]\n"
2093        ));
2094        let projects = accepted_fields(&format!(
2095            "{BASE}\n[[orgs]]\nname = \"O2\"\naccounts = [\"A\"]\n[[orgs.projects]]\n\
2096             zzz_unknown = 1\nname = \"P2\"\npath = \"/tmp/p2\"\n"
2097        ));
2098        let org_slack = accepted_fields(&format!(
2099            "{BASE}\n[[orgs]]\nname = \"O2\"\naccounts = [\"A\"]\n[orgs.slack]\nzzz_unknown = 1\n"
2100        ));
2101        let project_slack = accepted_fields(&format!(
2102            "{BASE}\n[[orgs]]\nname = \"O2\"\naccounts = [\"A\"]\n[[orgs.projects]]\nname = \
2103             \"P2\"\npath = \"/tmp/p2\"\n[orgs.projects.slack]\nzzz_unknown = 1\n"
2104        ));
2105        let project_handoff = accepted_fields(&format!(
2106            "{BASE}\n[[orgs]]\nname = \"O2\"\naccounts = [\"A\"]\n[[orgs.projects]]\nname = \
2107             \"P2\"\npath = \"/tmp/p2\"\n[orgs.projects.handoff]\nzzz_unknown = 1\n"
2108        ));
2109        let models = accepted_fields(&format!("{BASE}\n[models.\"m\"]\nzzz_unknown = 1\n"));
2110        let handoff = accepted_fields(&format!("{BASE}\n[handoff]\nzzz_unknown = 1\n"));
2111        let dictate = accepted_fields(&format!("{BASE}\n[dictate]\nzzz_unknown = 1\n"));
2112        let tts = accepted_fields(&format!("{BASE}\n[tts]\nzzz_unknown = 1\n"));
2113        let huggingface = accepted_fields(&format!("{BASE}\n[huggingface]\nzzz_unknown = 1\n"));
2114        let gotify = accepted_fields(&format!("{BASE}\n[gotify]\nzzz_unknown = 1\n"));
2115        let systemone = accepted_fields(&format!("{BASE}\n[systemone]\nzzz_unknown = 1\n"));
2116        let vault = accepted_fields(&format!("{BASE}\n[vault]\nzzz_unknown = 1\n"));
2117        let slack = accepted_fields(&format!("{BASE}\n[[slack]]\nzzz_unknown = 1\n"));
2118        let plugins = accepted_fields(&format!("{BASE}\n[plugins]\nzzz_unknown = 1\n"));
2119        let gateway = accepted_fields(&format!("{BASE}\n[gateway]\nzzz_unknown = 1\n"));
2120        let server = accepted_fields(&format!("{BASE}\n[server]\nzzz_unknown = 1\n"));
2121        let client = accepted_fields(&format!("{BASE}\n[client]\nzzz_unknown = 1\n"));
2122        let browser = accepted_fields(&format!("{BASE}\n[browser]\nzzz_unknown = 1\n"));
2123        let terminal = accepted_fields(&format!("{BASE}\n[terminal]\nzzz_unknown = 1\n"));
2124        let documents = accepted_fields(&format!("{BASE}\n[documents]\nzzz_unknown = 1\n"));
2125        let usage = accepted_fields(&format!("{BASE}\n[usage]\nzzz_unknown = 1\n"));
2126        let sessions = accepted_fields(&format!("{BASE}\n[sessions]\nzzz_unknown = 1\n"));
2127
2128        assert_eq!(
2129            models.iter().map(String::as_str).collect::<Vec<_>>(),
2130            ["auto_compact_window"],
2131            "the model table's fields moved",
2132        );
2133
2134        let mut accepted = BTreeSet::new();
2135        for field in &top {
2136            match field.as_str() {
2137                // The retired sections a stale file may still carry.
2138                "web" | "workers" | "projects" | "selection" | "ui" => {}
2139                "orgs" => accepted.extend(org_names_from(&orgs, &projects, &org_slack, &handoff)),
2140                "accounts" => accepted.extend(account_names_from(&accounts)),
2141                "models" => {
2142                    accepted.insert("models.*.auto_compact_window".to_owned());
2143                }
2144                "handoff" => accepted.extend(handoff_names(&handoff, "handoff.")),
2145                "dictate" => accepted.extend(
2146                    dictate
2147                        .iter()
2148                        .filter(|field| field.as_str() != "device")
2149                        .map(|field| format!("dictate.{field}")),
2150                ),
2151                "tts" => accepted.extend(
2152                    tts.iter()
2153                        .filter(|field| field.as_str() != "language")
2154                        .map(|field| format!("tts.{field}")),
2155                ),
2156                "huggingface" => {
2157                    accepted.extend(huggingface.iter().map(|field| format!("huggingface.{field}")));
2158                }
2159                "gotify" => accepted.extend(gotify.iter().map(|field| format!("gotify.{field}"))),
2160                "systemone" => {
2161                    accepted.extend(systemone.iter().map(|field| format!("systemone.{field}")));
2162                }
2163                "vault" => accepted.extend(vault.iter().map(|field| format!("vault.{field}"))),
2164                "slack" => accepted.extend(slack_names(&slack)),
2165                "plugins" => {
2166                    accepted.extend(plugins.iter().map(|field| format!("plugins.{field}")));
2167                }
2168                "gateway" => {
2169                    accepted.extend(gateway.iter().map(|field| format!("gateway.{field}")));
2170                }
2171                "server" => accepted.extend(server.iter().map(|field| format!("server.{field}"))),
2172                "client" => accepted.extend(client.iter().map(|field| format!("client.{field}"))),
2173                "browser" => {
2174                    accepted.extend(browser.iter().map(|field| format!("browser.{field}")));
2175                }
2176                "terminal" => {
2177                    accepted.extend(terminal.iter().map(|field| format!("terminal.{field}")));
2178                }
2179                "documents" => {
2180                    accepted.extend(documents.iter().map(|field| format!("documents.{field}")));
2181                }
2182                "usage" => accepted.extend(usage.iter().map(|field| format!("usage.{field}"))),
2183                "sessions" => {
2184                    accepted.extend(sessions.iter().map(|field| format!("sessions.{field}")));
2185                }
2186                "env" => {
2187                    accepted.insert("env.*".to_owned());
2188                }
2189                other => panic!("no catalogue rule for the top-level field `{other}`"),
2190            }
2191        }
2192        let catalogue: BTreeSet<String> = all_keys().map(|key| key.path.to_owned()).collect();
2193        assert_eq!(
2194            accepted, catalogue,
2195            "a field the loader accepts has no catalogue row, or the catalogue names a key the \
2196             loader does not accept",
2197        );
2198        // The project probes are read through the org expansion above; this
2199        // names them so a later edit cannot drop those two probes silently.
2200        assert!(!project_slack.is_empty(), "the project slack probe named no fields");
2201        assert!(!project_handoff.is_empty(), "the project handoff probe named no fields");
2202    }
2203
2204    fn accepted_fields(probe: &str) -> BTreeSet<String> {
2205        let error = load_error(probe);
2206        let message = error.to_string();
2207        let listed = message
2208            .split("expected ")
2209            .nth(1)
2210            .unwrap_or_else(|| panic!("serde named no accepted fields: {message}"));
2211        let listed = listed.strip_prefix("one of ").unwrap_or(listed);
2212        listed
2213            .split(", ")
2214            .flat_map(|field| field.split(" or "))
2215            .map(|field| field.trim().trim_matches('`').to_owned())
2216            .filter(|field| field != "zzz_unknown")
2217            .collect()
2218    }
2219
2220    fn org_names_from(
2221        orgs: &BTreeSet<String>,
2222        projects: &BTreeSet<String>,
2223        org_slack: &BTreeSet<String>,
2224        handoff: &BTreeSet<String>,
2225    ) -> Vec<String> {
2226        let mut names = Vec::new();
2227        for field in orgs {
2228            match field.as_str() {
2229                "slack" => {
2230                    names.extend(org_slack.iter().map(|field| format!("orgs[].slack.{field}")));
2231                }
2232                "projects" => names.extend(project_names(projects, org_slack, handoff)),
2233                other => names.push(format!("orgs[].{other}")),
2234            }
2235        }
2236        names
2237    }
2238
2239    /// A project's fields, with its own nested tables expanded by the
2240    /// probes taken at `[orgs.projects.slack]` and
2241    /// `[orgs.projects.handoff]`.
2242    fn project_names(
2243        projects: &BTreeSet<String>,
2244        project_slack: &BTreeSet<String>,
2245        handoff: &BTreeSet<String>,
2246    ) -> Vec<String> {
2247        let mut names = Vec::new();
2248        for field in projects {
2249            match field.as_str() {
2250                "env" => names.push("orgs[].projects[].env.*".to_owned()),
2251                "slack" => names.extend(
2252                    project_slack.iter().map(|field| format!("orgs[].projects[].slack.{field}")),
2253                ),
2254                "handoff" => names.extend(handoff_names(handoff, "orgs[].projects[].handoff.")),
2255                other => names.push(format!("orgs[].projects[].{other}")),
2256            }
2257        }
2258        names
2259    }
2260
2261    fn account_names_from(accounts: &BTreeSet<String>) -> Vec<String> {
2262        accounts
2263            .iter()
2264            .map(|field| match field.as_str() {
2265                "env" | "model_slugs" | "model_aliases" => format!("accounts[].{field}.*"),
2266                other => format!("accounts[].{other}"),
2267            })
2268            .collect()
2269    }
2270
2271    fn handoff_names(handoff: &BTreeSet<String>, prefix: &str) -> Vec<String> {
2272        handoff.iter().map(|field| format!("{prefix}{field}")).collect()
2273    }
2274
2275    fn slack_names(slack: &BTreeSet<String>) -> Vec<String> {
2276        slack.iter().map(|field| format!("slack[].{field}")).collect()
2277    }
2278
2279    /// **The socket's origin list edits as a list.** It is the one list key
2280    /// whose control the default text fallback would draw wrong.
2281    #[test]
2282    fn the_allowed_origins_edit_is_a_list() {
2283        assert_eq!(edit_spec("server.allowed_origins").kind, EditKind::List);
2284    }
2285
2286    /// Every catalogued key names an area the sub-nav draws, so no key is
2287    /// silently dropped from the document.
2288    #[test]
2289    fn every_key_names_a_drawn_area() {
2290        let drawn: std::collections::BTreeSet<&str> = AREAS.iter().map(|area| area.id).collect();
2291        for key in all_keys() {
2292            assert!(drawn.contains(key.area), "{} names no drawn area", key.path);
2293        }
2294    }
2295
2296    /// **A dotted name stays whole.** A model slug, an env flag, a display
2297    /// name or a project can contain dots, so neither a row's leaf nor its
2298    /// group may be cut at the last one.
2299    #[test]
2300    fn a_dotted_name_keeps_its_leaf_and_group() {
2301        let config = load(EVERY_KEY);
2302        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2303        let check = |area: &str, path: &str, leaf: &str, group: &[&str]| {
2304            let drawn = document
2305                .areas
2306                .iter()
2307                .find(|candidate| candidate.id == area)
2308                .and_then(|area| area.keys.iter().find(|key| key.path == path))
2309                .unwrap_or_else(|| panic!("{path} draws"));
2310            assert_eq!(drawn.leaf, leaf, "{path} leaf");
2311            let expected: Vec<String> = group.iter().map(|part| (*part).to_owned()).collect();
2312            assert_eq!(drawn.group, expected, "{path} group");
2313        };
2314        check(
2315            "accounts",
2316            "accounts.Primary.model_slugs.glm-5.3-flash",
2317            "glm-5.3-flash",
2318            &["Primary"],
2319        );
2320        check("accounts", "accounts.Primary.env.PROVIDER_FLAG", "PROVIDER_FLAG", &["Primary"]);
2321        check(
2322            "projects",
2323            "orgs.TestOrg.projects.proj.handoff.enabled",
2324            "enabled",
2325            &["TestOrg", "proj"],
2326        );
2327        check(
2328            "models",
2329            "models.glm-5.3-flash.auto_compact_window",
2330            "auto_compact_window",
2331            &["glm-5.3-flash"],
2332        );
2333        check("connectors", "slack.Test Slack.token", "token", &["slack", "Test Slack"]);
2334        check("server", "browser.fps", "fps", &["browser"]);
2335        check("usage", "usage.poll_seconds", "poll_seconds", &[]);
2336    }
2337
2338    /// **Two orgs keep their own headers.** The org's own rows carry no
2339    /// project segment, so without an org match they would fall into one
2340    /// unlabelled group and interleave.
2341    #[test]
2342    fn an_org_level_row_keeps_its_org_group() {
2343        let config = load(TWO_ORGS);
2344        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2345        let projects = document
2346            .areas
2347            .iter()
2348            .find(|area| area.id == "projects")
2349            .expect("the projects area draws");
2350        let row = |path: &str| {
2351            projects
2352                .keys
2353                .iter()
2354                .find(|key| key.path == path)
2355                .unwrap_or_else(|| panic!("{path} draws"))
2356        };
2357        let acme = row("orgs.Acme.Corp.name");
2358        assert_eq!(acme.group, vec!["Acme.Corp".to_owned()]);
2359        assert_eq!(acme.leaf, "name");
2360        let beta = row("orgs.Beta.name");
2361        assert_eq!(beta.group, vec!["Beta".to_owned()]);
2362        assert_ne!(acme.group, beta.group, "two orgs do not share a group");
2363        let slack = row("orgs.Acme.Corp.slack.workspace");
2364        assert_eq!(slack.group, vec!["Acme.Corp".to_owned()]);
2365        assert_eq!(slack.leaf, "workspace");
2366    }
2367
2368    /// A dotted display name, org and project each stay one name in the
2369    /// split, which the round-2 test's fixture could not exercise.
2370    #[test]
2371    fn a_dotted_account_org_and_project_stay_whole() {
2372        let config = load(TWO_ORGS);
2373        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2374        let find = |area: &str, path: &str| {
2375            document
2376                .areas
2377                .iter()
2378                .find(|candidate| candidate.id == area)
2379                .and_then(|area| area.keys.iter().find(|key| key.path == path))
2380                .unwrap_or_else(|| panic!("{path} draws"))
2381        };
2382        let token = find("accounts", "accounts.Acme.Corp.token");
2383        assert_eq!(token.group, vec!["Acme.Corp".to_owned()]);
2384        assert_eq!(token.leaf, "token");
2385        let model = find("projects", "orgs.Acme.Corp.projects.api.v2.model");
2386        assert_eq!(model.group, vec!["Acme.Corp".to_owned(), "api.v2".to_owned()]);
2387        assert_eq!(model.leaf, "model");
2388    }
2389
2390    /// Each class the page can draw has at least one key, so a class cannot
2391    /// become a legend entry nothing ever wears.
2392    #[test]
2393    fn every_effect_class_has_a_key() {
2394        for class in [
2395            EffectClass::Live,
2396            EffectClass::NextSession,
2397            EffectClass::Restart,
2398            EffectClass::NotReadYet,
2399        ] {
2400            assert!(all_keys().any(|key| key.class == class), "no key carries {class:?}");
2401        }
2402    }
2403
2404    /// Every catalogue entry resolves a row from the every-key fixture, so a
2405    /// resolver that forgot its value cannot pass as a drawn key.
2406    #[test]
2407    fn every_catalogued_key_resolves_a_row() {
2408        let config = load(EVERY_KEY);
2409        for key in all_keys() {
2410            assert!(
2411                !(key.rows)(&config).is_empty(),
2412                "{} resolved no row from a config that sets it",
2413                key.path,
2414            );
2415        }
2416    }
2417
2418    /// The document covers the thirteen areas and counts what it draws.
2419    #[test]
2420    fn the_document_covers_every_area_and_counts_its_keys() {
2421        let config = load(EVERY_KEY);
2422        let document = document(&config, "1.2.3", Path::new("/tmp/forge.toml"));
2423        assert_eq!(document.areas.len(), AREAS.len(), "every area is drawn");
2424        assert_eq!(document.areas.len(), 13, "the sub-nav's thirteen areas");
2425        assert_eq!(
2426            document.totals.keys,
2427            document.areas.iter().map(|area| area.keys.len()).sum::<usize>(),
2428            "the header's count is what the areas hold",
2429        );
2430        for area in &document.areas {
2431            assert!(!area.keys.is_empty(), "area {} drew no key", area.id);
2432        }
2433        assert_eq!(document.version, "1.2.3");
2434        assert_eq!(document.source, "/tmp/forge.toml");
2435    }
2436
2437    /// An axis the file does not set crosses as `null`, not as the display
2438    /// default: the page draws the built-in, and the client's axes path never
2439    /// applies a name it would resolve to nothing.
2440    #[test]
2441    fn an_unset_client_axis_is_null_in_the_document() {
2442        let raw = EVERY_KEY
2443            .replace("[client]\nmark = \"panes\"\ntheme = \"dark\"\nfont = \"system\"\n", "");
2444        assert!(!raw.contains("[client]"), "precondition: the client table was removed");
2445        let config = load(&raw);
2446        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2447        let client = document
2448            .areas
2449            .iter()
2450            .find(|area| area.id == "client")
2451            .expect("the client area is drawn");
2452        for path in ["client.mark", "client.theme", "client.font"] {
2453            let key = client
2454                .keys
2455                .iter()
2456                .find(|key| key.path == path)
2457                .unwrap_or_else(|| panic!("{path} has no row"));
2458            assert_eq!(
2459                key.value,
2460                serde_json::Value::Null,
2461                "{path} must carry the raw unset value, not a display default",
2462            );
2463        }
2464    }
2465
2466    /// A class split inside one table stays split in the reload summary: a
2467    /// live cap is not reported as a restart, and a port is not reported as
2468    /// live.
2469    #[test]
2470    fn the_reload_reports_each_key_by_its_own_class() {
2471        let previous = load(EVERY_KEY);
2472        let changed = EVERY_KEY
2473            .replace("first_frame_cap_kb = 64", "first_frame_cap_kb = 128")
2474            .replace("port = 8790", "port = 8791")
2475            .replace("auto_update = true", "auto_update = false")
2476            .replace("https://gotify.test", "https://gotify.example")
2477            .replace("shell = \"/bin/zsh\"", "shell = \"/bin/bash\"")
2478            .replace("idle_minutes = 15", "idle_minutes = 20");
2479        let next = load(&changed);
2480        let reload = diff(&previous, &next);
2481        assert!(
2482            reload.changed.iter().any(|key| key == "server.first_frame_cap_kb"),
2483            "the cap is live: {reload:?}",
2484        );
2485        assert!(
2486            reload.restart_only.iter().any(|key| key == "gotify.url"),
2487            "the stream captured its url at boot, so the change needs a restart: {reload:?}",
2488        );
2489        assert!(
2490            !reload.changed.iter().any(|key| key.starts_with("gotify.")),
2491            "a stream's url is not applied live: {reload:?}",
2492        );
2493        assert!(
2494            reload.restart_only.iter().any(|key| key == "server.port"),
2495            "the port needs a restart: {reload:?}",
2496        );
2497        assert!(
2498            !reload.changed.iter().any(|key| key == "server.port"),
2499            "the port is not live: {reload:?}",
2500        );
2501        assert!(
2502            !reload.changed.iter().any(|key| key.contains("plugins")),
2503            "a key nothing reads is not reported as applied: {reload:?}",
2504        );
2505        assert!(
2506            reload.changed.iter().any(|key| key == "terminal.idle_minutes"),
2507            "the idle clock reads live: {reload:?}",
2508        );
2509        assert!(
2510            reload.changed.iter().any(|key| key == "terminal.shell"),
2511            "a shell change applies at the next terminal spawn, not a restart: {reload:?}",
2512        );
2513        assert!(
2514            matches!(
2515                all_keys().find(|key| key.path == "terminal.shell").map(|key| key.class),
2516                Some(EffectClass::NextSession)
2517            ),
2518            "the shell's class is next session",
2519        );
2520    }
2521
2522    /// The voice area holds both directions: every `[dictate]` and `[tts]`
2523    /// key is drawn there rather than under the client that captures.
2524    #[test]
2525    fn the_voice_area_holds_both_directions() {
2526        let config = load(EVERY_KEY);
2527        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2528        let voice =
2529            document.areas.iter().find(|area| area.id == "voice").expect("the voice area is drawn");
2530        assert_eq!(voice.keys.len(), 17, "every [dictate] and [tts] key draws there");
2531        assert!(
2532            voice
2533                .keys
2534                .iter()
2535                .all(|key| key.path.starts_with("dictate.") || key.path.starts_with("tts.")),
2536            "no other table's key rides in the voice area",
2537        );
2538        assert!(
2539            voice.keys.iter().any(|key| key.path == "tts.voice"),
2540            "the speaking voice is a setting",
2541        );
2542        let groups: BTreeSet<Vec<String>> =
2543            voice.keys.iter().map(|key| key.group.clone()).collect();
2544        assert_eq!(
2545            groups,
2546            [vec!["dictate".to_owned()], vec!["tts".to_owned()]].into_iter().collect(),
2547            "each direction draws as its own box in the one voice area",
2548        );
2549        let client = document
2550            .areas
2551            .iter()
2552            .find(|area| area.id == "client")
2553            .expect("the client area is drawn");
2554        assert_eq!(client.keys.len(), 3, "the client area keeps its three axes only");
2555    }
2556
2557    /// A key nothing reads still changes the document, which is what makes
2558    /// the page show its new value even though the reload applies nothing.
2559    #[test]
2560    fn an_unread_key_changes_the_document_but_not_the_reload() {
2561        let previous = load(EVERY_KEY);
2562        let next = load(&EVERY_KEY.replace("auto_update = true", "auto_update = false"));
2563        let reload = diff(&previous, &next);
2564        assert!(reload.changed.is_empty(), "{reload:?}");
2565        assert!(reload.restart_only.is_empty(), "{reload:?}");
2566        assert_ne!(
2567            document(&previous, "v", Path::new("/tmp/forge.toml")),
2568            document(&next, "v", Path::new("/tmp/forge.toml")),
2569            "the page's value moved even though nothing applies it",
2570        );
2571    }
2572
2573    /// A config that did not move draws the same document, so a reload can
2574    /// skip the push.
2575    #[test]
2576    fn an_unchanged_file_draws_an_equal_document() {
2577        let previous = load(EVERY_KEY);
2578        let next = load(EVERY_KEY);
2579        assert_eq!(
2580            document(&previous, "v", Path::new("/tmp/forge.toml")),
2581            document(&next, "v", Path::new("/tmp/forge.toml")),
2582        );
2583    }
2584
2585    /// Keys may hold dots (`glm-5.3-flash`), so the walk separates
2586    /// segments with a character no key carries.
2587    const SEP: char = '\u{1f}';
2588
2589    fn collect_leaves(prefix: &str, value: &toml::Value, out: &mut Vec<String>) {
2590        match value {
2591            toml::Value::Table(table) => {
2592                for (key, value) in table {
2593                    let path =
2594                        if prefix.is_empty() { key.clone() } else { format!("{prefix}{SEP}{key}") };
2595                    collect_leaves(&path, value, out);
2596                }
2597            }
2598            toml::Value::Array(items) if items.iter().all(toml::Value::is_table) => {
2599                for item in items {
2600                    collect_leaves(&format!("{prefix}{SEP}[]"), item, out);
2601                }
2602            }
2603            _ => out.push(prefix.to_owned()),
2604        }
2605    }
2606
2607    fn normalize_path(path: &str) -> String {
2608        let segments: Vec<&str> = path.split(SEP).collect();
2609        let mut out = String::new();
2610        for (index, segment) in segments.iter().enumerate() {
2611            let previous = index.checked_sub(1).map_or("", |i| segments[i]);
2612            let before = index.checked_sub(2).map_or("", |i| segments[i]);
2613            let wildcard = previous == "env"
2614                || (previous == "[]" && before == "env")
2615                || previous == "model_slugs"
2616                || previous == "model_aliases"
2617                || (index == 1 && segments[0] == "models");
2618            let value = if wildcard { "*" } else { *segment };
2619            if value == "[]" {
2620                out.push_str("[]");
2621            } else if out.is_empty() {
2622                out.push_str(value);
2623            } else {
2624                out.push('.');
2625                out.push_str(value);
2626            }
2627        }
2628        out
2629    }
2630
2631    #[test]
2632    fn the_path_normaliser_folds_instances_into_their_pattern() {
2633        assert_eq!(normalize_path("accounts\u{1f}[]\u{1f}token"), "accounts[].token");
2634        assert_eq!(
2635            normalize_path("orgs\u{1f}[]\u{1f}projects\u{1f}[]\u{1f}env\u{1f}X"),
2636            "orgs[].projects[].env.*",
2637        );
2638        assert_eq!(
2639            normalize_path("models\u{1f}m\u{1f}auto_compact_window"),
2640            "models.*.auto_compact_window",
2641        );
2642        assert_eq!(
2643            normalize_path("accounts\u{1f}[]\u{1f}model_slugs\u{1f}glm-5.3-flash"),
2644            "accounts[].model_slugs.*",
2645        );
2646        assert_eq!(normalize_path("slack\u{1f}[]\u{1f}workspace"), "slack[].workspace");
2647    }
2648
2649    /// The fixture's own directory cannot outlive the test's config load, so
2650    /// the path is only ever read at load. This keeps `PathBuf` imported for
2651    /// the source assertions above where a path is built.
2652    #[test]
2653    fn the_source_path_is_the_one_the_builder_was_handed() {
2654        let config = load(EVERY_KEY);
2655        let source = PathBuf::from("/tmp/forge.toml");
2656        let document = document(&config, "v", &source);
2657        assert_eq!(document.source, source.display().to_string());
2658    }
2659
2660    const TWO_PROJECTS: &str = r#"
2661[[accounts]]
2662display_name = "Primary"
2663provider = "anthropic"
2664token = "t"
2665models = ["claude-sonnet-5"]
2666
2667[[orgs]]
2668name = "Acme"
2669accounts = ["Primary"]
2670
2671[[orgs.projects]]
2672name = "one"
2673path = "/tmp/one"
2674
2675[[orgs.projects]]
2676name = "two"
2677path = "/tmp/two"
2678env_file = "/tmp/two.env"
2679
2680[models."claude-sonnet-5"]
2681"#;
2682
2683    /// The index is the catalogue, not the document: one line per accepted
2684    /// key, and no value anywhere.
2685    #[test]
2686    fn the_index_carries_the_catalogue_and_no_value() {
2687        let config = load(EVERY_KEY);
2688        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2689
2690        let text = index_text(&config, &document, "TestOrg", "proj");
2691
2692        let entries = text.lines().filter(|line| line.starts_with("  ")).count();
2693        assert_eq!(
2694            entries,
2695            all_keys().count(),
2696            "the index's key set is the catalogue's, not a hand-written list: {text}",
2697        );
2698        for key in all_keys() {
2699            assert!(text.contains(key.path), "`{}` is missing from the index", key.path);
2700        }
2701        assert!(
2702            !text.contains("https://openrouter.ai/api"),
2703            "a value leaked into the index: {text}",
2704        );
2705        assert!(!text.contains("PROVIDER_FLAG"), "an env value leaked: {text}");
2706        assert!(
2707            !text.contains("sk-settings-index-sentinel"),
2708            "an account token leaked into the index: {text}",
2709        );
2710    }
2711
2712    /// The index teaches the caller the keys it can address, so another
2713    /// project's instance is not listed - its pattern still is.
2714    #[test]
2715    fn the_index_lists_the_callers_own_project_and_not_another() {
2716        let config = load(TWO_PROJECTS);
2717        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2718
2719        let text = index_text(&config, &document, "Acme", "one");
2720
2721        assert!(text.contains("Acme/one"), "the caller's project is named: {text}");
2722        assert!(!text.contains("Acme/two"), "another project's instance is not listed: {text}");
2723        assert!(text.contains("orgs[].projects[].model"), "the shape is still the catalogue's");
2724
2725        let line = |pattern: &str| {
2726            text.lines()
2727                .find(|line| line.trim_start().starts_with(&format!("{pattern}  ")))
2728                .unwrap_or_else(|| panic!("`{pattern}` is missing: {text}"))
2729        };
2730        assert!(
2731            line("orgs[].projects[].env_file").contains("unset"),
2732            "another project's env file does not mark the caller's index as set",
2733        );
2734        assert!(
2735            line("models.*.auto_compact_window").contains("unset"),
2736            "the resolver's `not set` placeholder is not a setting: {}",
2737            line("models.*.auto_compact_window"),
2738        );
2739
2740        let other = index_text(&config, &document, "Acme", "two");
2741        let line = other
2742            .lines()
2743            .find(|line| line.trim_start().starts_with("orgs[].projects[].env_file  "))
2744            .expect("the pattern is listed");
2745        assert!(line.contains("set"), "the caller's own env file reads as set: {line}");
2746    }
2747
2748    /// A tool's key resolves to the row the writer locates by, and an
2749    /// unknown key resolves to nothing rather than to a guess.
2750    #[test]
2751    fn a_target_is_the_documents_own_row() {
2752        let config = load(EVERY_KEY);
2753        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2754
2755        let resolved = target(&document, "accounts.Primary.token").expect("the row exists");
2756        assert_eq!(resolved.pattern, "accounts[].token");
2757        assert_eq!(resolved.area, "accounts");
2758        assert_eq!(resolved.current, json!("sk-settings-index-sentinel"));
2759        assert!(target(&document, "accounts.Nobody.token").is_none());
2760    }
2761
2762    /// A catalogue key the config does not set emits no row, and still
2763    /// resolves: the index lists it as unset, so `settings__get` and
2764    /// `settings__set` must be able to address it.
2765    #[test]
2766    fn an_optional_key_the_config_does_not_set_still_resolves() {
2767        let config = load(TWO_PROJECTS);
2768        let document = document(&config, "v", Path::new("/tmp/forge.toml"));
2769
2770        let project_key = resolve_target(&config, &document, "orgs.Acme.projects.one.env_file")
2771            .expect("an unset project optional resolves");
2772        assert_eq!(project_key.pattern, "orgs[].projects[].env_file");
2773        assert_eq!(project_key.current, serde_json::Value::Null);
2774
2775        let org_key = resolve_target(&config, &document, "orgs.Acme.slack.workspace")
2776            .expect("an unset org optional resolves");
2777        assert_eq!(org_key.pattern, "orgs[].slack.workspace");
2778        assert_eq!(org_key.current, serde_json::Value::Null);
2779
2780        let handoff = resolve_target(&config, &document, "orgs.Acme.projects.one.handoff.enabled")
2781            .expect("an unset handoff key resolves");
2782        assert_eq!(handoff.pattern, "orgs[].projects[].handoff.enabled");
2783
2784        assert!(
2785            resolve_target(&config, &document, "orgs.Acme.projects.nope.env_file").is_none(),
2786            "a project the config does not declare resolves to nothing",
2787        );
2788        assert!(
2789            resolve_target(&config, &document, "orgs.Nope.browser_profile").is_none(),
2790            "an org the config does not declare resolves to nothing",
2791        );
2792    }
2793
2794    /// The project a path belongs to is read off the config's own names, so
2795    /// a dotted project name stays one name.
2796    #[test]
2797    fn the_owning_project_is_read_off_the_path() {
2798        let config = load(TWO_PROJECTS);
2799        assert_eq!(
2800            owning_project(&config, "orgs.Acme.projects.two.max_workers")
2801                .map(|project| project.name.as_str()),
2802            Some("two"),
2803        );
2804        assert!(owning_project(&config, "server.port").is_none());
2805    }
2806}