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")]
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}