Skip to main content

meerkat_mobkit/
console_config.rs

1//! Console UI configuration loaded from application config.
2//!
3//! This is intentionally a view-level contract. Agent ownership and runtime
4//! routing still live in the mob/runtime layers; this config controls how the
5//! stock console chooses sidebar affordances and groups already-projected
6//! agents.
7
8use std::collections::BTreeMap;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ConsoleUiConfig {
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub title: Option<String>,
17    #[serde(default, skip_serializing_if = "ConsoleBrandingConfig::is_default")]
18    pub brand: ConsoleBrandingConfig,
19    #[serde(default, skip_serializing_if = "ConsoleAppearanceConfig::is_default")]
20    pub appearance: ConsoleAppearanceConfig,
21    #[serde(default, skip_serializing_if = "ConsoleEnvironmentConfig::is_default")]
22    pub environment: ConsoleEnvironmentConfig,
23    #[serde(default, skip_serializing_if = "ConsoleLayoutConfig::is_default")]
24    pub layout: ConsoleLayoutConfig,
25    #[serde(default, skip_serializing_if = "ConsoleRailUiConfig::is_default")]
26    pub rail: ConsoleRailUiConfig,
27    #[serde(default, skip_serializing_if = "ConsoleSidebarUiConfig::is_default")]
28    pub sidebar: ConsoleSidebarUiConfig,
29    #[serde(default, skip_serializing_if = "ConsoleAgentListConfig::is_default")]
30    pub agent_list: ConsoleAgentListConfig,
31    #[serde(default, skip_serializing_if = "ConsoleActionsUiConfig::is_default")]
32    pub actions: ConsoleActionsUiConfig,
33}
34
35impl ConsoleUiConfig {
36    pub fn is_default(value: &Self) -> bool {
37        value == &Self::default()
38    }
39
40    pub fn normalized(mut self) -> Self {
41        self.title = normalize_optional_string(self.title);
42        self.brand = self.brand.normalized();
43        self.appearance = self.appearance.normalized();
44        self.environment = self.environment.normalized();
45        self.layout = self.layout.normalized();
46        self.rail = self.rail.normalized();
47        self.sidebar = self.sidebar.normalized();
48        self.agent_list = self.agent_list.normalized();
49        self.actions = self.actions.normalized();
50        self
51    }
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ConsoleBrandingConfig {
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub label: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub logo_url: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub logo_alt: Option<String>,
62}
63
64impl ConsoleBrandingConfig {
65    pub fn is_default(value: &Self) -> bool {
66        value == &Self::default()
67    }
68
69    fn normalized(mut self) -> Self {
70        self.label = normalize_optional_string(self.label);
71        self.logo_url = normalize_optional_string(self.logo_url);
72        self.logo_alt = normalize_optional_string(self.logo_alt);
73        self
74    }
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ConsoleAppearanceConfig {
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub default_theme: Option<String>,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub default_variant: Option<String>,
83}
84
85impl ConsoleAppearanceConfig {
86    pub fn is_default(value: &Self) -> bool {
87        value == &Self::default()
88    }
89
90    fn normalized(mut self) -> Self {
91        self.default_theme = normalize_optional_string(self.default_theme);
92        self.default_variant = normalize_optional_string(self.default_variant);
93        self
94    }
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ConsoleEnvironmentConfig {
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub label: Option<String>,
101}
102
103impl ConsoleEnvironmentConfig {
104    pub fn is_default(value: &Self) -> bool {
105        value == &Self::default()
106    }
107
108    fn normalized(mut self) -> Self {
109        self.label = normalize_optional_string(self.label);
110        self
111    }
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ConsoleLayoutConfig {
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub initial_preset: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub initial_control: Option<String>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub initial_agent: Option<String>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub sidebar_collapsed: Option<bool>,
124}
125
126impl ConsoleLayoutConfig {
127    pub fn is_default(value: &Self) -> bool {
128        value == &Self::default()
129    }
130
131    fn normalized(mut self) -> Self {
132        self.initial_preset = normalize_optional_string(self.initial_preset);
133        self.initial_control = normalize_optional_string(self.initial_control);
134        self.initial_agent = normalize_optional_string(self.initial_agent);
135        self
136    }
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct ConsoleRailUiConfig {
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub visible: Option<bool>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub collapsed: Option<bool>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub active_preset_id: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub empty_text: Option<String>,
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub filter_presets: Vec<ConsoleRailFilterPresetConfig>,
151}
152
153impl ConsoleRailUiConfig {
154    pub fn is_default(value: &Self) -> bool {
155        value == &Self::default()
156    }
157
158    fn normalized(mut self) -> Self {
159        self.active_preset_id = normalize_optional_string(self.active_preset_id);
160        self.empty_text = normalize_optional_string(self.empty_text);
161        self.filter_presets = self
162            .filter_presets
163            .into_iter()
164            .map(ConsoleRailFilterPresetConfig::normalized)
165            .filter(|preset| !preset.id.is_empty() && !preset.label.is_empty())
166            .collect();
167        self
168    }
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub struct ConsoleRailFilterPresetConfig {
173    pub id: String,
174    pub label: String,
175    #[serde(
176        default,
177        rename = "watchedOnly",
178        alias = "watched_only",
179        skip_serializing_if = "Option::is_none"
180    )]
181    pub watched_only: Option<bool>,
182    #[serde(
183        default,
184        rename = "alertLevels",
185        alias = "alert_levels",
186        skip_serializing_if = "Vec::is_empty"
187    )]
188    pub alert_levels: Vec<String>,
189}
190
191impl ConsoleRailFilterPresetConfig {
192    fn normalized(mut self) -> Self {
193        self.id = self.id.trim().to_string();
194        self.label = self.label.trim().to_string();
195        self.alert_levels = normalize_string_vec(self.alert_levels);
196        self
197    }
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
201pub struct ConsoleSidebarUiConfig {
202    /// If present, only these stock workbench controls are visible.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub visible_controls: Option<Vec<String>>,
205    /// Stock workbench controls to hide when `visible_controls` is absent.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub hidden_controls: Vec<String>,
208    /// Extra sidebar buttons. Buttons can either open a stock `control` or
209    /// link to an `href`.
210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
211    pub buttons: Vec<ConsoleSidebarButtonConfig>,
212}
213
214impl ConsoleSidebarUiConfig {
215    pub fn is_default(value: &Self) -> bool {
216        value == &Self::default()
217    }
218
219    fn normalized(mut self) -> Self {
220        self.visible_controls = self.visible_controls.map(normalize_string_vec);
221        self.hidden_controls = normalize_string_vec(self.hidden_controls);
222        self.buttons = self
223            .buttons
224            .into_iter()
225            .map(ConsoleSidebarButtonConfig::normalized)
226            .filter(ConsoleSidebarButtonConfig::is_valid)
227            .collect();
228        self
229    }
230}
231
232#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ConsoleSidebarButtonConfig {
234    pub id: String,
235    pub label: String,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub control: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub href: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub target: Option<String>,
242    #[serde(default, alias = "iconName", skip_serializing_if = "Option::is_none")]
243    pub icon_name: Option<String>,
244}
245
246impl ConsoleSidebarButtonConfig {
247    fn normalized(mut self) -> Self {
248        self.id = self.id.trim().to_string();
249        self.label = self.label.trim().to_string();
250        self.control = normalize_optional_string(self.control);
251        self.href = normalize_optional_string(self.href);
252        self.target = normalize_optional_string(self.target);
253        self.icon_name = normalize_optional_string(self.icon_name);
254        self
255    }
256
257    fn is_valid(&self) -> bool {
258        !self.id.is_empty()
259            && !self.label.is_empty()
260            && (self.control.as_ref().is_some_and(|value| !value.is_empty())
261                || self.href.as_ref().is_some_and(|value| !value.is_empty()))
262    }
263}
264
265#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
266pub struct ConsoleAgentListConfig {
267    /// Selectors tried in order for the primary section. Supported selectors
268    /// include `group`, `role`, `kind`, and `labels.<key>`.
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub group_by: Vec<String>,
271    /// Selectors tried in order for an optional section-local subgroup.
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub subgroup_by: Vec<String>,
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    pub section_order: Vec<String>,
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub fallback_group: Option<String>,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub fallback_subgroup: Option<String>,
280    /// Defaults to true in the console: if a section only has one subgroup,
281    /// the subgroup header is suppressed.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub collapse_single_subgroup: Option<bool>,
284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
285    pub badges: Vec<ConsoleAgentBadgeConfig>,
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub sections: Vec<ConsoleAgentSectionConfig>,
288}
289
290impl ConsoleAgentListConfig {
291    pub fn is_default(value: &Self) -> bool {
292        value == &Self::default()
293    }
294
295    fn normalized(mut self) -> Self {
296        self.group_by = normalize_string_vec(self.group_by);
297        self.subgroup_by = normalize_string_vec(self.subgroup_by);
298        self.section_order = normalize_string_vec(self.section_order);
299        self.fallback_group = normalize_optional_string(self.fallback_group);
300        self.fallback_subgroup = normalize_optional_string(self.fallback_subgroup);
301        self.badges = self
302            .badges
303            .into_iter()
304            .map(ConsoleAgentBadgeConfig::normalized)
305            .filter(ConsoleAgentBadgeConfig::is_valid)
306            .collect();
307        self.sections = self
308            .sections
309            .into_iter()
310            .map(ConsoleAgentSectionConfig::normalized)
311            .filter(|section| !section.name.is_empty())
312            .collect();
313        self
314    }
315}
316
317#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
318pub struct ConsoleAgentBadgeConfig {
319    pub id: String,
320    pub label: String,
321    pub field: String,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub tone: Option<String>,
324}
325
326impl ConsoleAgentBadgeConfig {
327    fn normalized(mut self) -> Self {
328        self.id = self.id.trim().to_string();
329        self.label = self.label.trim().to_string();
330        self.field = self.field.trim().to_string();
331        self.tone = normalize_optional_string(self.tone);
332        self
333    }
334
335    fn is_valid(&self) -> bool {
336        !self.id.is_empty() && !self.label.is_empty() && !self.field.is_empty()
337    }
338}
339
340#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
341pub struct ConsoleAgentSectionConfig {
342    pub name: String,
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub collapsed: Option<bool>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub empty_title: Option<String>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub empty_text: Option<String>,
349}
350
351impl ConsoleAgentSectionConfig {
352    fn normalized(mut self) -> Self {
353        self.name = self.name.trim().to_string();
354        self.empty_title = normalize_optional_string(self.empty_title);
355        self.empty_text = normalize_optional_string(self.empty_text);
356        self
357    }
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
361pub struct ConsoleActionsUiConfig {
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub inspect_label: Option<String>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub chat_label: Option<String>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub send_label: Option<String>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub respawn_label: Option<String>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub retire_label: Option<String>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub reset_label: Option<String>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub show_inspect: Option<bool>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub show_chat: Option<bool>,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub show_respawn: Option<bool>,
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub show_retire: Option<bool>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub show_reset: Option<bool>,
384}
385
386impl ConsoleActionsUiConfig {
387    pub fn is_default(value: &Self) -> bool {
388        value == &Self::default()
389    }
390
391    fn normalized(mut self) -> Self {
392        self.inspect_label = normalize_optional_string(self.inspect_label);
393        self.chat_label = normalize_optional_string(self.chat_label);
394        self.send_label = normalize_optional_string(self.send_label);
395        self.respawn_label = normalize_optional_string(self.respawn_label);
396        self.retire_label = normalize_optional_string(self.retire_label);
397        self.reset_label = normalize_optional_string(self.reset_label);
398        self
399    }
400}
401
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub enum ConsoleConfigError {
404    Io(String),
405    TomlParse(String),
406}
407
408impl std::fmt::Display for ConsoleConfigError {
409    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410        match self {
411            Self::Io(message) => write!(f, "I/O error: {message}"),
412            Self::TomlParse(message) => write!(f, "TOML parse error: {message}"),
413        }
414    }
415}
416
417impl std::error::Error for ConsoleConfigError {}
418
419#[derive(Debug, Clone, Default, Deserialize)]
420struct ConsoleUiConfigPatch {
421    #[serde(default)]
422    title: Option<String>,
423    #[serde(default)]
424    brand: Option<ConsoleBrandingConfigPatch>,
425    #[serde(default)]
426    appearance: Option<ConsoleAppearanceConfigPatch>,
427    #[serde(default)]
428    environment: Option<ConsoleEnvironmentConfigPatch>,
429    #[serde(default)]
430    layout: Option<ConsoleLayoutConfigPatch>,
431    #[serde(default)]
432    rail: Option<ConsoleRailUiConfigPatch>,
433    #[serde(default)]
434    sidebar: Option<ConsoleSidebarUiConfigPatch>,
435    #[serde(default)]
436    agent_list: Option<ConsoleAgentListConfigPatch>,
437    #[serde(default)]
438    actions: Option<ConsoleActionsUiConfigPatch>,
439    #[serde(default)]
440    realms: BTreeMap<String, ConsoleUiConfigPatch>,
441}
442
443impl ConsoleUiConfigPatch {
444    fn apply_to(&self, config: &mut ConsoleUiConfig) {
445        if let Some(title) = &self.title {
446            config.title = normalize_optional_string(Some(title.clone()));
447        }
448        if let Some(brand) = &self.brand {
449            brand.apply_to(&mut config.brand);
450        }
451        if let Some(appearance) = &self.appearance {
452            appearance.apply_to(&mut config.appearance);
453        }
454        if let Some(environment) = &self.environment {
455            environment.apply_to(&mut config.environment);
456        }
457        if let Some(layout) = &self.layout {
458            layout.apply_to(&mut config.layout);
459        }
460        if let Some(rail) = &self.rail {
461            rail.apply_to(&mut config.rail);
462        }
463        if let Some(sidebar) = &self.sidebar {
464            sidebar.apply_to(&mut config.sidebar);
465        }
466        if let Some(agent_list) = &self.agent_list {
467            agent_list.apply_to(&mut config.agent_list);
468        }
469        if let Some(actions) = &self.actions {
470            actions.apply_to(&mut config.actions);
471        }
472    }
473}
474
475#[derive(Debug, Clone, Default, Deserialize)]
476struct ConsoleBrandingConfigPatch {
477    #[serde(default)]
478    label: Option<String>,
479    #[serde(default)]
480    logo_url: Option<String>,
481    #[serde(default)]
482    logo_alt: Option<String>,
483}
484
485impl ConsoleBrandingConfigPatch {
486    fn apply_to(&self, config: &mut ConsoleBrandingConfig) {
487        if let Some(label) = &self.label {
488            config.label = normalize_optional_string(Some(label.clone()));
489        }
490        if let Some(logo_url) = &self.logo_url {
491            config.logo_url = normalize_optional_string(Some(logo_url.clone()));
492        }
493        if let Some(logo_alt) = &self.logo_alt {
494            config.logo_alt = normalize_optional_string(Some(logo_alt.clone()));
495        }
496    }
497}
498
499#[derive(Debug, Clone, Default, Deserialize)]
500struct ConsoleAppearanceConfigPatch {
501    #[serde(default)]
502    default_theme: Option<String>,
503    #[serde(default)]
504    default_variant: Option<String>,
505}
506
507impl ConsoleAppearanceConfigPatch {
508    fn apply_to(&self, config: &mut ConsoleAppearanceConfig) {
509        if let Some(default_theme) = &self.default_theme {
510            config.default_theme = normalize_optional_string(Some(default_theme.clone()));
511        }
512        if let Some(default_variant) = &self.default_variant {
513            config.default_variant = normalize_optional_string(Some(default_variant.clone()));
514        }
515    }
516}
517
518#[derive(Debug, Clone, Default, Deserialize)]
519struct ConsoleEnvironmentConfigPatch {
520    #[serde(default)]
521    label: Option<String>,
522}
523
524impl ConsoleEnvironmentConfigPatch {
525    fn apply_to(&self, config: &mut ConsoleEnvironmentConfig) {
526        if let Some(label) = &self.label {
527            config.label = normalize_optional_string(Some(label.clone()));
528        }
529    }
530}
531
532#[derive(Debug, Clone, Default, Deserialize)]
533struct ConsoleLayoutConfigPatch {
534    #[serde(default)]
535    initial_preset: Option<String>,
536    #[serde(default)]
537    initial_control: Option<String>,
538    #[serde(default)]
539    initial_agent: Option<String>,
540    #[serde(default)]
541    sidebar_collapsed: Option<bool>,
542}
543
544impl ConsoleLayoutConfigPatch {
545    fn apply_to(&self, config: &mut ConsoleLayoutConfig) {
546        if let Some(initial_preset) = &self.initial_preset {
547            config.initial_preset = normalize_optional_string(Some(initial_preset.clone()));
548        }
549        if let Some(initial_control) = &self.initial_control {
550            config.initial_control = normalize_optional_string(Some(initial_control.clone()));
551        }
552        if let Some(initial_agent) = &self.initial_agent {
553            config.initial_agent = normalize_optional_string(Some(initial_agent.clone()));
554        }
555        if let Some(sidebar_collapsed) = self.sidebar_collapsed {
556            config.sidebar_collapsed = Some(sidebar_collapsed);
557        }
558    }
559}
560
561#[derive(Debug, Clone, Default, Deserialize)]
562struct ConsoleRailUiConfigPatch {
563    #[serde(default)]
564    visible: Option<bool>,
565    #[serde(default)]
566    collapsed: Option<bool>,
567    #[serde(default)]
568    active_preset_id: Option<String>,
569    #[serde(default)]
570    empty_text: Option<String>,
571    #[serde(default)]
572    filter_presets: Option<Vec<ConsoleRailFilterPresetConfig>>,
573}
574
575impl ConsoleRailUiConfigPatch {
576    fn apply_to(&self, config: &mut ConsoleRailUiConfig) {
577        if let Some(visible) = self.visible {
578            config.visible = Some(visible);
579        }
580        if let Some(collapsed) = self.collapsed {
581            config.collapsed = Some(collapsed);
582        }
583        if let Some(active_preset_id) = &self.active_preset_id {
584            config.active_preset_id = normalize_optional_string(Some(active_preset_id.clone()));
585        }
586        if let Some(empty_text) = &self.empty_text {
587            config.empty_text = normalize_optional_string(Some(empty_text.clone()));
588        }
589        if let Some(filter_presets) = &self.filter_presets {
590            config.filter_presets = filter_presets
591                .iter()
592                .cloned()
593                .map(ConsoleRailFilterPresetConfig::normalized)
594                .filter(|preset| !preset.id.is_empty() && !preset.label.is_empty())
595                .collect();
596        }
597    }
598}
599
600#[derive(Debug, Clone, Default, Deserialize)]
601struct ConsoleSidebarUiConfigPatch {
602    #[serde(default)]
603    visible_controls: Option<Vec<String>>,
604    #[serde(default)]
605    hidden_controls: Option<Vec<String>>,
606    #[serde(default)]
607    buttons: Option<Vec<ConsoleSidebarButtonConfig>>,
608}
609
610impl ConsoleSidebarUiConfigPatch {
611    fn apply_to(&self, config: &mut ConsoleSidebarUiConfig) {
612        if let Some(visible_controls) = &self.visible_controls {
613            config.visible_controls = Some(normalize_string_vec(visible_controls.clone()));
614        }
615        if let Some(hidden_controls) = &self.hidden_controls {
616            config.hidden_controls = normalize_string_vec(hidden_controls.clone());
617        }
618        if let Some(buttons) = &self.buttons {
619            config.buttons = buttons
620                .iter()
621                .cloned()
622                .map(ConsoleSidebarButtonConfig::normalized)
623                .filter(ConsoleSidebarButtonConfig::is_valid)
624                .collect();
625        }
626    }
627}
628
629#[derive(Debug, Clone, Default, Deserialize)]
630struct ConsoleAgentListConfigPatch {
631    #[serde(default)]
632    group_by: Option<Vec<String>>,
633    #[serde(default)]
634    subgroup_by: Option<Vec<String>>,
635    #[serde(default)]
636    section_order: Option<Vec<String>>,
637    #[serde(default)]
638    fallback_group: Option<String>,
639    #[serde(default)]
640    fallback_subgroup: Option<String>,
641    #[serde(default)]
642    collapse_single_subgroup: Option<bool>,
643    #[serde(default)]
644    badges: Option<Vec<ConsoleAgentBadgeConfig>>,
645    #[serde(default)]
646    sections: Option<Vec<ConsoleAgentSectionConfig>>,
647}
648
649impl ConsoleAgentListConfigPatch {
650    fn apply_to(&self, config: &mut ConsoleAgentListConfig) {
651        if let Some(group_by) = &self.group_by {
652            config.group_by = normalize_string_vec(group_by.clone());
653        }
654        if let Some(subgroup_by) = &self.subgroup_by {
655            config.subgroup_by = normalize_string_vec(subgroup_by.clone());
656        }
657        if let Some(section_order) = &self.section_order {
658            config.section_order = normalize_string_vec(section_order.clone());
659        }
660        if let Some(fallback_group) = &self.fallback_group {
661            config.fallback_group = normalize_optional_string(Some(fallback_group.clone()));
662        }
663        if let Some(fallback_subgroup) = &self.fallback_subgroup {
664            config.fallback_subgroup = normalize_optional_string(Some(fallback_subgroup.clone()));
665        }
666        if let Some(collapse_single_subgroup) = self.collapse_single_subgroup {
667            config.collapse_single_subgroup = Some(collapse_single_subgroup);
668        }
669        if let Some(badges) = &self.badges {
670            config.badges = badges
671                .iter()
672                .cloned()
673                .map(ConsoleAgentBadgeConfig::normalized)
674                .filter(ConsoleAgentBadgeConfig::is_valid)
675                .collect();
676        }
677        if let Some(sections) = &self.sections {
678            config.sections = sections
679                .iter()
680                .cloned()
681                .map(ConsoleAgentSectionConfig::normalized)
682                .filter(|section| !section.name.is_empty())
683                .collect();
684        }
685    }
686}
687
688#[derive(Debug, Clone, Default, Deserialize)]
689struct ConsoleActionsUiConfigPatch {
690    #[serde(default)]
691    inspect_label: Option<String>,
692    #[serde(default)]
693    chat_label: Option<String>,
694    #[serde(default)]
695    send_label: Option<String>,
696    #[serde(default)]
697    respawn_label: Option<String>,
698    #[serde(default)]
699    retire_label: Option<String>,
700    #[serde(default)]
701    reset_label: Option<String>,
702    #[serde(default)]
703    show_inspect: Option<bool>,
704    #[serde(default)]
705    show_chat: Option<bool>,
706    #[serde(default)]
707    show_respawn: Option<bool>,
708    #[serde(default)]
709    show_retire: Option<bool>,
710    #[serde(default)]
711    show_reset: Option<bool>,
712}
713
714impl ConsoleActionsUiConfigPatch {
715    fn apply_to(&self, config: &mut ConsoleActionsUiConfig) {
716        if let Some(inspect_label) = &self.inspect_label {
717            config.inspect_label = normalize_optional_string(Some(inspect_label.clone()));
718        }
719        if let Some(chat_label) = &self.chat_label {
720            config.chat_label = normalize_optional_string(Some(chat_label.clone()));
721        }
722        if let Some(send_label) = &self.send_label {
723            config.send_label = normalize_optional_string(Some(send_label.clone()));
724        }
725        if let Some(respawn_label) = &self.respawn_label {
726            config.respawn_label = normalize_optional_string(Some(respawn_label.clone()));
727        }
728        if let Some(retire_label) = &self.retire_label {
729            config.retire_label = normalize_optional_string(Some(retire_label.clone()));
730        }
731        if let Some(reset_label) = &self.reset_label {
732            config.reset_label = normalize_optional_string(Some(reset_label.clone()));
733        }
734        if let Some(show_inspect) = self.show_inspect {
735            config.show_inspect = Some(show_inspect);
736        }
737        if let Some(show_chat) = self.show_chat {
738            config.show_chat = Some(show_chat);
739        }
740        if let Some(show_respawn) = self.show_respawn {
741            config.show_respawn = Some(show_respawn);
742        }
743        if let Some(show_retire) = self.show_retire {
744            config.show_retire = Some(show_retire);
745        }
746        if let Some(show_reset) = self.show_reset {
747            config.show_reset = Some(show_reset);
748        }
749    }
750}
751
752pub fn load_console_ui_config_from_toml(
753    toml_text: &str,
754) -> Result<ConsoleUiConfig, ConsoleConfigError> {
755    load_console_ui_config_from_toml_for_realm(toml_text, None)
756}
757
758pub fn load_console_ui_config_from_toml_for_realm(
759    toml_text: &str,
760    realm: Option<&str>,
761) -> Result<ConsoleUiConfig, ConsoleConfigError> {
762    let patch: ConsoleUiConfigPatch =
763        toml::from_str(toml_text).map_err(|err| ConsoleConfigError::TomlParse(err.to_string()))?;
764    let mut config = ConsoleUiConfig::default();
765    patch.apply_to(&mut config);
766    if let Some(realm) = realm.map(str::trim).filter(|value| !value.is_empty())
767        && let Some(overlay) = patch.realms.get(realm)
768    {
769        overlay.apply_to(&mut config);
770    }
771    Ok(config.normalized())
772}
773
774pub fn load_console_ui_config_from_path_for_realm(
775    path: impl AsRef<Path>,
776    realm: Option<&str>,
777) -> Result<ConsoleUiConfig, ConsoleConfigError> {
778    let path = path.as_ref();
779    let text = std::fs::read_to_string(path).map_err(|err| {
780        ConsoleConfigError::Io(format!("failed to read {}: {err}", path.display()))
781    })?;
782    load_console_ui_config_from_toml_for_realm(&text, realm)
783}
784
785fn normalize_string_vec(values: Vec<String>) -> Vec<String> {
786    values
787        .into_iter()
788        .map(|value| value.trim().to_string())
789        .filter(|value| !value.is_empty())
790        .collect()
791}
792
793fn normalize_optional_string(value: Option<String>) -> Option<String> {
794    value
795        .map(|value| value.trim().to_string())
796        .filter(|value| !value.is_empty())
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802
803    #[test]
804    fn loads_console_toml_with_sidebar_buttons_and_agent_selectors()
805    -> Result<(), ConsoleConfigError> {
806        let config = load_console_ui_config_from_toml(
807            r#"
808title = "OB3"
809
810[brand]
811label = "Open Brain"
812logo_url = "/assets/ob3.svg"
813logo_alt = "OB3"
814
815[appearance]
816default_theme = "dark"
817default_variant = "graphite"
818
819[environment]
820label = "prod"
821
822[layout]
823initial_preset = "two_columns"
824initial_control = "roster"
825sidebar_collapsed = false
826
827[rail]
828visible = true
829collapsed = false
830active_preset_id = "critical"
831empty_text = "No signals."
832
833[[rail.filter_presets]]
834id = "critical"
835label = "Critical"
836alert_levels = ["critical"]
837
838[sidebar]
839visible_controls = ["topology", "roster", "logs"]
840
841[[sidebar.buttons]]
842id = "ob3"
843label = "OB3"
844href = "https://example.test/ob3"
845target = "_blank"
846
847[agent_list]
848group_by = ["labels.console_group", "labels.group", "role"]
849subgroup_by = ["labels.org"]
850section_order = ["Personal", "Initiatives", "Internal"]
851fallback_group = "Other"
852
853[[agent_list.badges]]
854id = "org"
855label = "Org"
856field = "labels.org"
857tone = "info"
858
859[[agent_list.sections]]
860name = "Initiatives"
861empty_title = "No initiatives"
862empty_text = "Create one in Linear."
863
864[actions]
865inspect_label = "Profile"
866chat_label = "Talk"
867send_label = "Send to agent"
868show_reset = false
869"#,
870        )?;
871
872        assert_eq!(config.title.as_deref(), Some("OB3"));
873        assert_eq!(config.brand.label.as_deref(), Some("Open Brain"));
874        assert_eq!(config.brand.logo_url.as_deref(), Some("/assets/ob3.svg"));
875        assert_eq!(config.brand.logo_alt.as_deref(), Some("OB3"));
876        assert_eq!(config.appearance.default_theme.as_deref(), Some("dark"));
877        assert_eq!(config.environment.label.as_deref(), Some("prod"));
878        assert_eq!(config.layout.initial_preset.as_deref(), Some("two_columns"));
879        assert_eq!(config.rail.filter_presets[0].alert_levels, vec!["critical"]);
880        assert_eq!(
881            config.sidebar.visible_controls,
882            Some(vec![
883                "topology".to_string(),
884                "roster".to_string(),
885                "logs".to_string()
886            ])
887        );
888        assert_eq!(config.sidebar.buttons.len(), 1);
889        assert_eq!(config.agent_list.subgroup_by, vec!["labels.org"]);
890        assert_eq!(config.agent_list.badges[0].field, "labels.org");
891        assert_eq!(config.agent_list.sections[0].name, "Initiatives");
892        assert_eq!(config.actions.inspect_label.as_deref(), Some("Profile"));
893        assert_eq!(config.actions.show_reset, Some(false));
894        Ok(())
895    }
896
897    #[test]
898    fn realm_overlay_replaces_only_configured_fields() -> Result<(), ConsoleConfigError> {
899        let config = load_console_ui_config_from_toml_for_realm(
900            r#"
901title = "Default"
902
903[sidebar]
904visible_controls = ["topology", "roster"]
905
906[agent_list]
907group_by = ["labels.group"]
908
909[realms.ob3]
910title = "OB3"
911
912[realms.ob3.brand]
913label = "OB3"
914
915[realms.ob3.agent_list]
916subgroup_by = ["labels.org"]
917
918[realms.ob3.layout]
919initial_control = "logs"
920"#,
921            Some("ob3"),
922        )?;
923
924        assert_eq!(config.title.as_deref(), Some("OB3"));
925        assert_eq!(
926            config.sidebar.visible_controls,
927            Some(vec!["topology".to_string(), "roster".to_string()])
928        );
929        assert_eq!(config.brand.label.as_deref(), Some("OB3"));
930        assert_eq!(config.layout.initial_control.as_deref(), Some("logs"));
931        assert_eq!(config.agent_list.group_by, vec!["labels.group"]);
932        assert_eq!(config.agent_list.subgroup_by, vec!["labels.org"]);
933        Ok(())
934    }
935}