1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub visible_controls: Option<Vec<String>>,
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub hidden_controls: Vec<String>,
208 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
270 pub group_by: Vec<String>,
271 #[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 #[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}