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    /// Optional default pinned agents for hosts that want a starter view.
285    /// User localStorage preferences take precedence once present.
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub default_pinned_agent_ids: Vec<String>,
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub badges: Vec<ConsoleAgentBadgeConfig>,
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub sections: Vec<ConsoleAgentSectionConfig>,
292}
293
294impl ConsoleAgentListConfig {
295    pub fn is_default(value: &Self) -> bool {
296        value == &Self::default()
297    }
298
299    fn normalized(mut self) -> Self {
300        self.group_by = normalize_string_vec(self.group_by);
301        self.subgroup_by = normalize_string_vec(self.subgroup_by);
302        self.section_order = normalize_string_vec(self.section_order);
303        self.fallback_group = normalize_optional_string(self.fallback_group);
304        self.fallback_subgroup = normalize_optional_string(self.fallback_subgroup);
305        self.default_pinned_agent_ids = normalize_string_vec(self.default_pinned_agent_ids);
306        self.badges = self
307            .badges
308            .into_iter()
309            .map(ConsoleAgentBadgeConfig::normalized)
310            .filter(ConsoleAgentBadgeConfig::is_valid)
311            .collect();
312        self.sections = self
313            .sections
314            .into_iter()
315            .map(ConsoleAgentSectionConfig::normalized)
316            .filter(|section| !section.name.is_empty())
317            .collect();
318        self
319    }
320}
321
322#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
323pub struct ConsoleAgentBadgeConfig {
324    pub id: String,
325    pub label: String,
326    pub field: String,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub tone: Option<String>,
329}
330
331impl ConsoleAgentBadgeConfig {
332    fn normalized(mut self) -> Self {
333        self.id = self.id.trim().to_string();
334        self.label = self.label.trim().to_string();
335        self.field = self.field.trim().to_string();
336        self.tone = normalize_optional_string(self.tone);
337        self
338    }
339
340    fn is_valid(&self) -> bool {
341        !self.id.is_empty() && !self.label.is_empty() && !self.field.is_empty()
342    }
343}
344
345#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
346pub struct ConsoleAgentSectionConfig {
347    pub name: String,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub collapsed: Option<bool>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub empty_title: Option<String>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub empty_text: Option<String>,
354}
355
356impl ConsoleAgentSectionConfig {
357    fn normalized(mut self) -> Self {
358        self.name = self.name.trim().to_string();
359        self.empty_title = normalize_optional_string(self.empty_title);
360        self.empty_text = normalize_optional_string(self.empty_text);
361        self
362    }
363}
364
365#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
366pub struct ConsoleActionsUiConfig {
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub inspect_label: Option<String>,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub chat_label: Option<String>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub send_label: Option<String>,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub respawn_label: Option<String>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub retire_label: Option<String>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub reset_label: Option<String>,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub show_inspect: Option<bool>,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub show_chat: Option<bool>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub show_respawn: Option<bool>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub show_retire: Option<bool>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub show_reset: Option<bool>,
389}
390
391impl ConsoleActionsUiConfig {
392    pub fn is_default(value: &Self) -> bool {
393        value == &Self::default()
394    }
395
396    fn normalized(mut self) -> Self {
397        self.inspect_label = normalize_optional_string(self.inspect_label);
398        self.chat_label = normalize_optional_string(self.chat_label);
399        self.send_label = normalize_optional_string(self.send_label);
400        self.respawn_label = normalize_optional_string(self.respawn_label);
401        self.retire_label = normalize_optional_string(self.retire_label);
402        self.reset_label = normalize_optional_string(self.reset_label);
403        self
404    }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub enum ConsoleConfigError {
409    Io(String),
410    TomlParse(String),
411}
412
413impl std::fmt::Display for ConsoleConfigError {
414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        match self {
416            Self::Io(message) => write!(f, "I/O error: {message}"),
417            Self::TomlParse(message) => write!(f, "TOML parse error: {message}"),
418        }
419    }
420}
421
422impl std::error::Error for ConsoleConfigError {}
423
424#[derive(Debug, Clone, Default, Deserialize)]
425struct ConsoleUiConfigPatch {
426    #[serde(default)]
427    title: Option<String>,
428    #[serde(default)]
429    brand: Option<ConsoleBrandingConfigPatch>,
430    #[serde(default)]
431    appearance: Option<ConsoleAppearanceConfigPatch>,
432    #[serde(default)]
433    environment: Option<ConsoleEnvironmentConfigPatch>,
434    #[serde(default)]
435    layout: Option<ConsoleLayoutConfigPatch>,
436    #[serde(default)]
437    rail: Option<ConsoleRailUiConfigPatch>,
438    #[serde(default)]
439    sidebar: Option<ConsoleSidebarUiConfigPatch>,
440    #[serde(default)]
441    agent_list: Option<ConsoleAgentListConfigPatch>,
442    #[serde(default)]
443    actions: Option<ConsoleActionsUiConfigPatch>,
444    #[serde(default)]
445    realms: BTreeMap<String, ConsoleUiConfigPatch>,
446}
447
448impl ConsoleUiConfigPatch {
449    fn apply_to(&self, config: &mut ConsoleUiConfig) {
450        if let Some(title) = &self.title {
451            config.title = normalize_optional_string(Some(title.clone()));
452        }
453        if let Some(brand) = &self.brand {
454            brand.apply_to(&mut config.brand);
455        }
456        if let Some(appearance) = &self.appearance {
457            appearance.apply_to(&mut config.appearance);
458        }
459        if let Some(environment) = &self.environment {
460            environment.apply_to(&mut config.environment);
461        }
462        if let Some(layout) = &self.layout {
463            layout.apply_to(&mut config.layout);
464        }
465        if let Some(rail) = &self.rail {
466            rail.apply_to(&mut config.rail);
467        }
468        if let Some(sidebar) = &self.sidebar {
469            sidebar.apply_to(&mut config.sidebar);
470        }
471        if let Some(agent_list) = &self.agent_list {
472            agent_list.apply_to(&mut config.agent_list);
473        }
474        if let Some(actions) = &self.actions {
475            actions.apply_to(&mut config.actions);
476        }
477    }
478}
479
480#[derive(Debug, Clone, Default, Deserialize)]
481struct ConsoleBrandingConfigPatch {
482    #[serde(default)]
483    label: Option<String>,
484    #[serde(default)]
485    logo_url: Option<String>,
486    #[serde(default)]
487    logo_alt: Option<String>,
488}
489
490impl ConsoleBrandingConfigPatch {
491    fn apply_to(&self, config: &mut ConsoleBrandingConfig) {
492        if let Some(label) = &self.label {
493            config.label = normalize_optional_string(Some(label.clone()));
494        }
495        if let Some(logo_url) = &self.logo_url {
496            config.logo_url = normalize_optional_string(Some(logo_url.clone()));
497        }
498        if let Some(logo_alt) = &self.logo_alt {
499            config.logo_alt = normalize_optional_string(Some(logo_alt.clone()));
500        }
501    }
502}
503
504#[derive(Debug, Clone, Default, Deserialize)]
505struct ConsoleAppearanceConfigPatch {
506    #[serde(default)]
507    default_theme: Option<String>,
508    #[serde(default)]
509    default_variant: Option<String>,
510}
511
512impl ConsoleAppearanceConfigPatch {
513    fn apply_to(&self, config: &mut ConsoleAppearanceConfig) {
514        if let Some(default_theme) = &self.default_theme {
515            config.default_theme = normalize_optional_string(Some(default_theme.clone()));
516        }
517        if let Some(default_variant) = &self.default_variant {
518            config.default_variant = normalize_optional_string(Some(default_variant.clone()));
519        }
520    }
521}
522
523#[derive(Debug, Clone, Default, Deserialize)]
524struct ConsoleEnvironmentConfigPatch {
525    #[serde(default)]
526    label: Option<String>,
527}
528
529impl ConsoleEnvironmentConfigPatch {
530    fn apply_to(&self, config: &mut ConsoleEnvironmentConfig) {
531        if let Some(label) = &self.label {
532            config.label = normalize_optional_string(Some(label.clone()));
533        }
534    }
535}
536
537#[derive(Debug, Clone, Default, Deserialize)]
538struct ConsoleLayoutConfigPatch {
539    #[serde(default)]
540    initial_preset: Option<String>,
541    #[serde(default)]
542    initial_control: Option<String>,
543    #[serde(default)]
544    initial_agent: Option<String>,
545    #[serde(default)]
546    sidebar_collapsed: Option<bool>,
547}
548
549impl ConsoleLayoutConfigPatch {
550    fn apply_to(&self, config: &mut ConsoleLayoutConfig) {
551        if let Some(initial_preset) = &self.initial_preset {
552            config.initial_preset = normalize_optional_string(Some(initial_preset.clone()));
553        }
554        if let Some(initial_control) = &self.initial_control {
555            config.initial_control = normalize_optional_string(Some(initial_control.clone()));
556        }
557        if let Some(initial_agent) = &self.initial_agent {
558            config.initial_agent = normalize_optional_string(Some(initial_agent.clone()));
559        }
560        if let Some(sidebar_collapsed) = self.sidebar_collapsed {
561            config.sidebar_collapsed = Some(sidebar_collapsed);
562        }
563    }
564}
565
566#[derive(Debug, Clone, Default, Deserialize)]
567struct ConsoleRailUiConfigPatch {
568    #[serde(default)]
569    visible: Option<bool>,
570    #[serde(default)]
571    collapsed: Option<bool>,
572    #[serde(default)]
573    active_preset_id: Option<String>,
574    #[serde(default)]
575    empty_text: Option<String>,
576    #[serde(default)]
577    filter_presets: Option<Vec<ConsoleRailFilterPresetConfig>>,
578}
579
580impl ConsoleRailUiConfigPatch {
581    fn apply_to(&self, config: &mut ConsoleRailUiConfig) {
582        if let Some(visible) = self.visible {
583            config.visible = Some(visible);
584        }
585        if let Some(collapsed) = self.collapsed {
586            config.collapsed = Some(collapsed);
587        }
588        if let Some(active_preset_id) = &self.active_preset_id {
589            config.active_preset_id = normalize_optional_string(Some(active_preset_id.clone()));
590        }
591        if let Some(empty_text) = &self.empty_text {
592            config.empty_text = normalize_optional_string(Some(empty_text.clone()));
593        }
594        if let Some(filter_presets) = &self.filter_presets {
595            config.filter_presets = filter_presets
596                .iter()
597                .cloned()
598                .map(ConsoleRailFilterPresetConfig::normalized)
599                .filter(|preset| !preset.id.is_empty() && !preset.label.is_empty())
600                .collect();
601        }
602    }
603}
604
605#[derive(Debug, Clone, Default, Deserialize)]
606struct ConsoleSidebarUiConfigPatch {
607    #[serde(default)]
608    visible_controls: Option<Vec<String>>,
609    #[serde(default)]
610    hidden_controls: Option<Vec<String>>,
611    #[serde(default)]
612    buttons: Option<Vec<ConsoleSidebarButtonConfig>>,
613}
614
615impl ConsoleSidebarUiConfigPatch {
616    fn apply_to(&self, config: &mut ConsoleSidebarUiConfig) {
617        if let Some(visible_controls) = &self.visible_controls {
618            config.visible_controls = Some(normalize_string_vec(visible_controls.clone()));
619        }
620        if let Some(hidden_controls) = &self.hidden_controls {
621            config.hidden_controls = normalize_string_vec(hidden_controls.clone());
622        }
623        if let Some(buttons) = &self.buttons {
624            config.buttons = buttons
625                .iter()
626                .cloned()
627                .map(ConsoleSidebarButtonConfig::normalized)
628                .filter(ConsoleSidebarButtonConfig::is_valid)
629                .collect();
630        }
631    }
632}
633
634#[derive(Debug, Clone, Default, Deserialize)]
635struct ConsoleAgentListConfigPatch {
636    #[serde(default)]
637    group_by: Option<Vec<String>>,
638    #[serde(default)]
639    subgroup_by: Option<Vec<String>>,
640    #[serde(default)]
641    section_order: Option<Vec<String>>,
642    #[serde(default)]
643    fallback_group: Option<String>,
644    #[serde(default)]
645    fallback_subgroup: Option<String>,
646    #[serde(default)]
647    collapse_single_subgroup: Option<bool>,
648    #[serde(default)]
649    default_pinned_agent_ids: Option<Vec<String>>,
650    #[serde(default)]
651    badges: Option<Vec<ConsoleAgentBadgeConfig>>,
652    #[serde(default)]
653    sections: Option<Vec<ConsoleAgentSectionConfig>>,
654}
655
656impl ConsoleAgentListConfigPatch {
657    fn apply_to(&self, config: &mut ConsoleAgentListConfig) {
658        if let Some(group_by) = &self.group_by {
659            config.group_by = normalize_string_vec(group_by.clone());
660        }
661        if let Some(subgroup_by) = &self.subgroup_by {
662            config.subgroup_by = normalize_string_vec(subgroup_by.clone());
663        }
664        if let Some(section_order) = &self.section_order {
665            config.section_order = normalize_string_vec(section_order.clone());
666        }
667        if let Some(fallback_group) = &self.fallback_group {
668            config.fallback_group = normalize_optional_string(Some(fallback_group.clone()));
669        }
670        if let Some(fallback_subgroup) = &self.fallback_subgroup {
671            config.fallback_subgroup = normalize_optional_string(Some(fallback_subgroup.clone()));
672        }
673        if let Some(collapse_single_subgroup) = self.collapse_single_subgroup {
674            config.collapse_single_subgroup = Some(collapse_single_subgroup);
675        }
676        if let Some(default_pinned_agent_ids) = &self.default_pinned_agent_ids {
677            config.default_pinned_agent_ids =
678                normalize_string_vec(default_pinned_agent_ids.clone());
679        }
680        if let Some(badges) = &self.badges {
681            config.badges = badges
682                .iter()
683                .cloned()
684                .map(ConsoleAgentBadgeConfig::normalized)
685                .filter(ConsoleAgentBadgeConfig::is_valid)
686                .collect();
687        }
688        if let Some(sections) = &self.sections {
689            config.sections = sections
690                .iter()
691                .cloned()
692                .map(ConsoleAgentSectionConfig::normalized)
693                .filter(|section| !section.name.is_empty())
694                .collect();
695        }
696    }
697}
698
699#[derive(Debug, Clone, Default, Deserialize)]
700struct ConsoleActionsUiConfigPatch {
701    #[serde(default)]
702    inspect_label: Option<String>,
703    #[serde(default)]
704    chat_label: Option<String>,
705    #[serde(default)]
706    send_label: Option<String>,
707    #[serde(default)]
708    respawn_label: Option<String>,
709    #[serde(default)]
710    retire_label: Option<String>,
711    #[serde(default)]
712    reset_label: Option<String>,
713    #[serde(default)]
714    show_inspect: Option<bool>,
715    #[serde(default)]
716    show_chat: Option<bool>,
717    #[serde(default)]
718    show_respawn: Option<bool>,
719    #[serde(default)]
720    show_retire: Option<bool>,
721    #[serde(default)]
722    show_reset: Option<bool>,
723}
724
725impl ConsoleActionsUiConfigPatch {
726    fn apply_to(&self, config: &mut ConsoleActionsUiConfig) {
727        if let Some(inspect_label) = &self.inspect_label {
728            config.inspect_label = normalize_optional_string(Some(inspect_label.clone()));
729        }
730        if let Some(chat_label) = &self.chat_label {
731            config.chat_label = normalize_optional_string(Some(chat_label.clone()));
732        }
733        if let Some(send_label) = &self.send_label {
734            config.send_label = normalize_optional_string(Some(send_label.clone()));
735        }
736        if let Some(respawn_label) = &self.respawn_label {
737            config.respawn_label = normalize_optional_string(Some(respawn_label.clone()));
738        }
739        if let Some(retire_label) = &self.retire_label {
740            config.retire_label = normalize_optional_string(Some(retire_label.clone()));
741        }
742        if let Some(reset_label) = &self.reset_label {
743            config.reset_label = normalize_optional_string(Some(reset_label.clone()));
744        }
745        if let Some(show_inspect) = self.show_inspect {
746            config.show_inspect = Some(show_inspect);
747        }
748        if let Some(show_chat) = self.show_chat {
749            config.show_chat = Some(show_chat);
750        }
751        if let Some(show_respawn) = self.show_respawn {
752            config.show_respawn = Some(show_respawn);
753        }
754        if let Some(show_retire) = self.show_retire {
755            config.show_retire = Some(show_retire);
756        }
757        if let Some(show_reset) = self.show_reset {
758            config.show_reset = Some(show_reset);
759        }
760    }
761}
762
763pub fn load_console_ui_config_from_toml(
764    toml_text: &str,
765) -> Result<ConsoleUiConfig, ConsoleConfigError> {
766    load_console_ui_config_from_toml_for_realm(toml_text, None)
767}
768
769pub fn load_console_ui_config_from_toml_for_realm(
770    toml_text: &str,
771    realm: Option<&str>,
772) -> Result<ConsoleUiConfig, ConsoleConfigError> {
773    let patch: ConsoleUiConfigPatch =
774        toml::from_str(toml_text).map_err(|err| ConsoleConfigError::TomlParse(err.to_string()))?;
775    let mut config = ConsoleUiConfig::default();
776    patch.apply_to(&mut config);
777    if let Some(realm) = realm.map(str::trim).filter(|value| !value.is_empty())
778        && let Some(overlay) = patch.realms.get(realm)
779    {
780        overlay.apply_to(&mut config);
781    }
782    Ok(config.normalized())
783}
784
785pub fn load_console_ui_config_from_path_for_realm(
786    path: impl AsRef<Path>,
787    realm: Option<&str>,
788) -> Result<ConsoleUiConfig, ConsoleConfigError> {
789    let path = path.as_ref();
790    let text = std::fs::read_to_string(path).map_err(|err| {
791        ConsoleConfigError::Io(format!("failed to read {}: {err}", path.display()))
792    })?;
793    load_console_ui_config_from_toml_for_realm(&text, realm)
794}
795
796fn normalize_string_vec(values: Vec<String>) -> Vec<String> {
797    values
798        .into_iter()
799        .map(|value| value.trim().to_string())
800        .filter(|value| !value.is_empty())
801        .collect()
802}
803
804fn normalize_optional_string(value: Option<String>) -> Option<String> {
805    value
806        .map(|value| value.trim().to_string())
807        .filter(|value| !value.is_empty())
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn loads_console_toml_with_sidebar_buttons_and_agent_selectors()
816    -> Result<(), ConsoleConfigError> {
817        let config = load_console_ui_config_from_toml(
818            r#"
819title = "OB3"
820
821[brand]
822label = "Open Brain"
823logo_url = "/assets/ob3.svg"
824logo_alt = "OB3"
825
826[appearance]
827default_theme = "dark"
828default_variant = "graphite"
829
830[environment]
831label = "prod"
832
833[layout]
834initial_preset = "two_columns"
835initial_control = "roster"
836sidebar_collapsed = false
837
838[rail]
839visible = true
840collapsed = false
841active_preset_id = "critical"
842empty_text = "No signals."
843
844[[rail.filter_presets]]
845id = "critical"
846label = "Critical"
847alert_levels = ["critical"]
848
849[sidebar]
850visible_controls = ["topology", "roster", "logs"]
851
852[[sidebar.buttons]]
853id = "ob3"
854label = "OB3"
855href = "https://example.test/ob3"
856target = "_blank"
857
858[agent_list]
859group_by = ["labels.console_group", "labels.group", "role"]
860subgroup_by = ["labels.org"]
861section_order = ["Personal", "Initiatives", "Internal"]
862fallback_group = "Other"
863default_pinned_agent_ids = ["identity:ops-lead"]
864
865[[agent_list.badges]]
866id = "org"
867label = "Org"
868field = "labels.org"
869tone = "info"
870
871[[agent_list.sections]]
872name = "Initiatives"
873empty_title = "No initiatives"
874empty_text = "Create one in Linear."
875
876[actions]
877inspect_label = "Profile"
878chat_label = "Talk"
879send_label = "Send to agent"
880show_reset = false
881"#,
882        )?;
883
884        assert_eq!(config.title.as_deref(), Some("OB3"));
885        assert_eq!(config.brand.label.as_deref(), Some("Open Brain"));
886        assert_eq!(config.brand.logo_url.as_deref(), Some("/assets/ob3.svg"));
887        assert_eq!(config.brand.logo_alt.as_deref(), Some("OB3"));
888        assert_eq!(config.appearance.default_theme.as_deref(), Some("dark"));
889        assert_eq!(config.environment.label.as_deref(), Some("prod"));
890        assert_eq!(config.layout.initial_preset.as_deref(), Some("two_columns"));
891        assert_eq!(config.rail.filter_presets[0].alert_levels, vec!["critical"]);
892        assert_eq!(
893            config.sidebar.visible_controls,
894            Some(vec![
895                "topology".to_string(),
896                "roster".to_string(),
897                "logs".to_string()
898            ])
899        );
900        assert_eq!(config.sidebar.buttons.len(), 1);
901        assert_eq!(config.agent_list.subgroup_by, vec!["labels.org"]);
902        assert_eq!(
903            config.agent_list.default_pinned_agent_ids,
904            vec!["identity:ops-lead"]
905        );
906        assert_eq!(config.agent_list.badges[0].field, "labels.org");
907        assert_eq!(config.agent_list.sections[0].name, "Initiatives");
908        assert_eq!(config.actions.inspect_label.as_deref(), Some("Profile"));
909        assert_eq!(config.actions.show_reset, Some(false));
910        Ok(())
911    }
912
913    #[test]
914    fn realm_overlay_replaces_only_configured_fields() -> Result<(), ConsoleConfigError> {
915        let config = load_console_ui_config_from_toml_for_realm(
916            r#"
917title = "Default"
918
919[sidebar]
920visible_controls = ["topology", "roster"]
921
922[agent_list]
923group_by = ["labels.group"]
924
925[realms.ob3]
926title = "OB3"
927
928[realms.ob3.brand]
929label = "OB3"
930
931[realms.ob3.agent_list]
932subgroup_by = ["labels.org"]
933
934[realms.ob3.layout]
935initial_control = "logs"
936"#,
937            Some("ob3"),
938        )?;
939
940        assert_eq!(config.title.as_deref(), Some("OB3"));
941        assert_eq!(
942            config.sidebar.visible_controls,
943            Some(vec!["topology".to_string(), "roster".to_string()])
944        );
945        assert_eq!(config.brand.label.as_deref(), Some("OB3"));
946        assert_eq!(config.layout.initial_control.as_deref(), Some("logs"));
947        assert_eq!(config.agent_list.group_by, vec!["labels.group"]);
948        assert_eq!(config.agent_list.subgroup_by, vec!["labels.org"]);
949        Ok(())
950    }
951}