1use 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
23pub struct AreaSpec {
26 pub id: &'static str,
27 pub name: &'static str,
28 pub note: &'static str,
29}
30
31pub 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#[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
104fn 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#[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
168fn edit_spec(path: &str) -> EditSpec {
172 match path {
173 "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[].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 "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 "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.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.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.*.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 _ => 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
271pub 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 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
1160const 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
1363fn 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 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 "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
1439fn 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
1461fn 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
1476pub(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
1514pub(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
1525pub(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
1545pub(crate) struct CatalogueKeyTarget {
1548 pub class: EffectClass,
1549 pub edit: SettingsEdit,
1550}
1551
1552pub(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
1560pub(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 kind: SettingKind::String,
1573 value: serde_json::Value::Null,
1574 class: catalogue.class,
1575 edit: catalogue.edit,
1576 })
1577}
1578
1579pub(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 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
1624fn 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 true
1645}
1646
1647pub(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
1665pub(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
1681fn 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
1692fn 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
1724fn index_value_set(value: &serde_json::Value) -> bool {
1728 !value.is_null() && value.as_str() != Some("not set")
1729}
1730
1731fn 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
1745pub(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
1792pub(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 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 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 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 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 #[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 #[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 "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 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 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 #[test]
2282 fn the_allowed_origins_edit_is_a_list() {
2283 assert_eq!(edit_spec("server.allowed_origins").kind, EditKind::List);
2284 }
2285
2286 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}