Skip to main content

agentmux_srv/backend/wconfig/
state.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Thread-safe in-memory configuration holder.
5
6use std::sync::{Arc, RwLock};
7
8use super::types::{FullConfigType, SettingsType};
9
10/// Thread-safe in-memory holder for the live configuration.
11///
12/// **This does not watch anything.** It was called `ConfigWatcher` until
13/// 2026-09-06, on the strength of a comment promising that "the actual file
14/// system watching will be integrated with the event loop in a later phase" —
15/// which never happened. Watching was instead built separately in
16/// `backend::config_watcher_fs`, on top of the shared `fs_watch::pool`, and the
17/// two have coexisted since: similar names, one module doing the thing the
18/// other is named for. That cost real time while tracing the settings-reload
19/// path; see
20/// `docs/reports/REPORT_NETWORK_ARCHITECTURE_DRYNESS_AND_ROBUST_LAN_2026_09_06.md` §7.
21///
22/// Readers get a cheap `Arc` clone of the current snapshot; writers swap the
23/// whole `Arc` under the lock, so no reader ever observes a half-applied
24/// config.
25pub struct ConfigState {
26    config: RwLock<Arc<FullConfigType>>,
27}
28
29impl ConfigState {
30    /// Create a new config watcher with default config.
31    pub fn new() -> Self {
32        Self {
33            config: RwLock::new(Arc::new(FullConfigType::default())),
34        }
35    }
36
37    /// Create a new config watcher with initial config.
38    pub fn with_config(config: FullConfigType) -> Self {
39        Self {
40            config: RwLock::new(Arc::new(config)),
41        }
42    }
43
44    /// Get a snapshot of the current config.
45    pub fn get_full_config(&self) -> Arc<FullConfigType> {
46        self.config.read().unwrap().clone()
47    }
48
49    /// Get just the settings.
50    pub fn get_settings(&self) -> SettingsType {
51        self.config.read().unwrap().settings.clone()
52    }
53
54    /// Update just the settings portion, and the per-connection settings that
55    /// live inside it (`settings.json` → `connections`): every
56    /// per-connection reader looks at `FullConfigType::connections`.
57    pub fn update_settings(&self, settings: SettingsType) {
58        let mut current = self.config.write().unwrap();
59        let mut new_config = (**current).clone();
60        new_config.connections = settings.connections.clone();
61        new_config.settings = settings;
62        *current = Arc::new(new_config);
63    }
64
65    /// Replace the widget set (the built-ins merged with the user's own
66    /// `widgets.json` — `backend::user_widgets`).
67    pub fn update_widgets(&self, widgets: std::collections::HashMap<String, super::WidgetConfigType>) {
68        let mut current = self.config.write().unwrap();
69        let mut new_config = (**current).clone();
70        new_config.widgets = widgets;
71        *current = Arc::new(new_config);
72    }
73
74    /// Update just the browser start page. Mirrors `update_settings` exactly
75    /// — see `backend::browser_start_page` for the load/watch wiring that
76    /// calls this.
77    pub fn update_browser_start_page(&self, url: Option<String>) {
78        let mut current = self.config.write().unwrap();
79        let mut new_config = (**current).clone();
80        new_config.browser_start_page = url;
81        *current = Arc::new(new_config);
82    }
83}
84
85impl Default for ConfigState {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    /// `settings.json` → `connections.<name>` reaches `FullConfigType::connections`,
96    /// which the per-connection readers use (durable default, theme, …). It used to
97    /// fall into `SettingsType::extra`, so no connection setting ever took effect.
98    #[test]
99    fn connection_settings_in_settings_json_reach_the_full_config() {
100        let settings: SettingsType = serde_json::from_value(serde_json::json!({
101            "term:theme": "default-dark",
102            "connections": {
103                "db1": { "term:durable": false, "display:name": "prod-db", "display:color": "#e5484d" }
104            }
105        }))
106        .unwrap();
107        assert!(!settings.extra.contains_key("connections"), "typed, not a catch-all key");
108
109        let state = ConfigState::new();
110        state.update_settings(settings);
111        let config = state.get_full_config();
112        let db1 = config.connections.get("db1").expect("db1's settings are loaded");
113        assert_eq!(db1.term_durable, Some(false));
114        assert_eq!(db1.display_name, "prod-db");
115        assert_eq!(db1.display_color, "#e5484d");
116    }
117
118    #[test]
119    fn removing_a_connection_from_settings_removes_its_settings() {
120        let state = ConfigState::new();
121        state.update_settings(
122            serde_json::from_value(serde_json::json!({ "connections": { "db1": { "display:pinned": true } } })).unwrap(),
123        );
124        state.update_settings(SettingsType::default());
125        assert!(state.get_full_config().connections.is_empty());
126    }
127
128    /// One wrong-typed value in one connection's entry costs only that entry,
129    /// not the whole settings file.
130    #[test]
131    fn a_bad_connection_entry_does_not_fail_the_settings() {
132        let settings: SettingsType = serde_json::from_value(serde_json::json!({
133            "term:theme": "default-dark",
134            "connections": {
135                "bad": { "term:durable": "yes" },
136                "good": { "display:pinned": true }
137            }
138        }))
139        .expect("the file still parses");
140        assert_eq!(settings.term_theme, "default-dark");
141        assert!(!settings.connections.contains_key("bad"));
142        assert_eq!(settings.connections["good"].display_pinned, Some(true));
143
144        let not_an_object: SettingsType =
145            serde_json::from_value(serde_json::json!({ "term:theme": "x", "connections": "nope" })).unwrap();
146        assert_eq!(not_an_object.term_theme, "x");
147        assert!(not_an_object.connections.is_empty());
148    }
149}