Skip to main content

agentmux_srv/backend/wconfig/
loader.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Config file loading, parsing, template merging, and environment expansion.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use super::types::{ConfigError, FullConfigType, WidgetConfigType};
10
11// ---- Default config builder ----
12
13/// Build the initial default configuration with embedded default assets.
14///
15/// Loads the bundled `widgets.json` (from `config/`) at compile time
16/// and populates `FullConfigType.widgets` so the frontend widget bar is populated on startup.
17pub fn build_default_config() -> FullConfigType {
18    let mut config = FullConfigType::default();
19
20    // Embed widgets.json at compile time (equivalent to Go's //go:embed)
21    const WIDGETS_JSON: &str =
22        include_str!("../../config/widgets.json");
23
24    match serde_json::from_str::<HashMap<String, WidgetConfigType>>(WIDGETS_JSON) {
25        Ok(widgets) => {
26            validate_widget_configs(&widgets);
27            config.widgets = widgets;
28        }
29        Err(e) => {
30            eprintln!("wconfig: failed to parse embedded widgets.json: {}", e);
31        }
32    }
33
34    config
35}
36
37/// Soft validation for widget entries loaded from `widgets.json`: a widget
38/// must be either a leaf (non-empty `blockdef.meta`) or a parent (non-empty
39/// `children`), never neither, never both. Logged as a startup warning, not
40/// a hard failure, matching how other config issues are handled.
41pub(crate) fn validate_widget_configs(widgets: &HashMap<String, WidgetConfigType>) {
42    for (key, widget) in widgets {
43        let has_blockdef = !widget.block_def.meta.is_empty();
44        let has_children = !widget.children.is_empty();
45        if has_blockdef && has_children {
46            eprintln!(
47                "wconfig: widget \"{}\" has both a blockdef and children — children (parent behavior) takes precedence",
48                key
49            );
50        } else if !has_blockdef && !has_children {
51            eprintln!(
52                "wconfig: widget \"{}\" has neither a blockdef nor children — it can't open a pane or expand a submenu",
53                key
54            );
55        }
56    }
57}
58
59// ---- Config loading helpers ----
60
61/// Read a JSON config file, returning default on missing/error.
62pub fn read_config_file<T: serde::de::DeserializeOwned + Default>(
63    path: &PathBuf,
64) -> (T, Vec<ConfigError>) {
65    let mut errors = Vec::new();
66
67    let content = match std::fs::read_to_string(path) {
68        Ok(c) => c,
69        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (T::default(), errors),
70        Err(e) => {
71            errors.push(ConfigError {
72                file: path.to_string_lossy().to_string(),
73                err: format!("cannot read file: {}", e),
74            });
75            return (T::default(), errors);
76        }
77    };
78
79    // Strip // and /* */ comments so users can annotate settings.json
80    let stripped = json_comments::StripComments::new(content.as_bytes());
81    let mut json_bytes = Vec::new();
82    std::io::Read::read_to_end(&mut std::io::BufReader::new(stripped), &mut json_bytes)
83        .unwrap_or_default();
84
85    // Strip trailing commas before } or ] (common when commented-out lines follow values)
86    let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
87    let clean: Result<T, _> = serde_json::from_str(&json_str);
88
89    match clean {
90        Ok(parsed) => (parsed, errors),
91        Err(e) => {
92            errors.push(ConfigError {
93                file: path.to_string_lossy().to_string(),
94                err: format!("JSON parse error: {}", e),
95            });
96            (T::default(), errors)
97        }
98    }
99}
100
101/// Read `settings.json` as a raw `serde_json::Value::Object`, stripping JSONC comments
102/// and trailing commas. Returns an empty object if the file doesn't exist or can't be parsed.
103pub fn read_settings_raw_jsonc(path: &std::path::Path) -> serde_json::Map<String, serde_json::Value> {
104    if !path.exists() {
105        return serde_json::Map::new();
106    }
107    match std::fs::read_to_string(path) {
108        Ok(content) => parse_jsonc_to_map(&content),
109        Err(_) => serde_json::Map::new(),
110    }
111}
112
113/// Parse a JSONC string (with // comments and trailing commas) into a flat JSON map.
114pub fn parse_jsonc_to_map(content: &str) -> serde_json::Map<String, serde_json::Value> {
115    let stripped_comments = json_comments::StripComments::new(content.as_bytes());
116    let mut json_bytes = Vec::new();
117    std::io::Read::read_to_end(&mut std::io::BufReader::new(stripped_comments), &mut json_bytes)
118        .unwrap_or_default();
119    let json_str = strip_trailing_commas(&String::from_utf8_lossy(&json_bytes));
120    match serde_json::from_str::<serde_json::Value>(&json_str) {
121        Ok(serde_json::Value::Object(map)) => map,
122        _ => serde_json::Map::new(),
123    }
124}
125
126/// Merge user settings into a JSONC template string.
127///
128/// For each user key:
129/// - If the key exists as a commented-out line in the template (`// "key": ...`),
130///   that line is replaced with the uncommented user value.
131/// - If the key is NOT in the template, it is appended before the closing `}`.
132///
133/// The result is always a valid JSONC file with the full template structure intact.
134pub fn merge_into_template(
135    template: &str,
136    user_settings: &serde_json::Map<String, serde_json::Value>,
137) -> String {
138    if user_settings.is_empty() {
139        return template.to_string();
140    }
141
142    let mut remaining: std::collections::HashMap<&str, &serde_json::Value> =
143        user_settings.iter().map(|(k, v)| (k.as_str(), v)).collect();
144    let mut lines: Vec<String> = Vec::new();
145
146    for line in template.lines() {
147        if let Some(key) = extract_commented_setting_key(line) {
148            if let Some(value) = remaining.remove(key) {
149                // Preserve the original indentation
150                let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
151                let val_str = serde_json::to_string(value).unwrap_or_default();
152                lines.push(format!("{}\"{}\": {},", indent, key, val_str));
153                continue;
154            }
155        }
156        lines.push(line.to_string());
157    }
158
159    // Append any remaining user settings not found in the template
160    if !remaining.is_empty() {
161        // Find the last `}` and insert before it
162        if let Some(brace_pos) = lines.iter().rposition(|l| l.trim() == "}") {
163            let mut extra: Vec<String> = Vec::new();
164            extra.push(String::new());
165            extra.push("    // -- User Overrides --".to_string());
166            let mut sorted_keys: Vec<&&str> = remaining.keys().collect();
167            sorted_keys.sort();
168            for key in sorted_keys {
169                let value = remaining[*key];
170                let val_str = serde_json::to_string(value).unwrap_or_default();
171                extra.push(format!("    \"{}\": {},", key, val_str));
172            }
173            for (i, line) in extra.into_iter().enumerate() {
174                lines.insert(brace_pos + i, line);
175            }
176        }
177    }
178
179    let mut result = lines.join("\n");
180    // Ensure file ends with newline
181    if !result.ends_with('\n') {
182        result.push('\n');
183    }
184    result
185}
186
187/// Extract the settings key from a commented-out template line.
188/// Matches lines like: `    // "some:key":   value,`
189/// Returns `Some("some:key")` or `None`.
190fn extract_commented_setting_key(line: &str) -> Option<&str> {
191    let trimmed = line.trim_start();
192    let rest = trimmed.strip_prefix("//")?;
193    let rest = rest.trim_start();
194    let rest = rest.strip_prefix('"')?;
195    let end = rest.find('"')?;
196    Some(&rest[..end])
197}
198
199pub(super) fn strip_trailing_commas(input: &str) -> String {
200    let mut result = String::with_capacity(input.len());
201    let mut chars = input.chars().peekable();
202    let mut in_string = false;
203    let mut last_comma_pos: Option<usize> = None;
204
205    while let Some(ch) = chars.next() {
206        if in_string {
207            result.push(ch);
208            if ch == '\\' {
209                if let Some(&next) = chars.peek() {
210                    result.push(next);
211                    chars.next();
212                }
213            } else if ch == '"' {
214                in_string = false;
215            }
216        } else {
217            match ch {
218                '"' => {
219                    in_string = true;
220                    last_comma_pos = None;
221                    result.push(ch);
222                }
223                ',' => {
224                    last_comma_pos = Some(result.len());
225                    result.push(ch);
226                }
227                '}' | ']' => {
228                    if let Some(pos) = last_comma_pos {
229                        result.replace_range(pos..pos + 1, " ");
230                    }
231                    last_comma_pos = None;
232                    result.push(ch);
233                }
234                c if c.is_whitespace() => {
235                    result.push(ch);
236                }
237                _ => {
238                    last_comma_pos = None;
239                    result.push(ch);
240                }
241            }
242        }
243    }
244    result
245}
246
247/// Replace `$ENV:VAR_NAME` and `$ENV:VAR_NAME:fallback` in a string.
248#[allow(dead_code)]
249pub fn expand_env_vars(s: &str) -> String {
250    let mut result = s.to_string();
251    let mut start = 0;
252
253    while let Some(idx) = result[start..].find("$ENV:") {
254        let abs_idx = start + idx;
255        let rest = &result[abs_idx + 5..];
256
257        // Find the end of the variable reference
258        let end = rest
259            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != ':')
260            .unwrap_or(rest.len());
261
262        let var_spec = &rest[..end];
263
264        // Split on first colon for fallback
265        let (var_name, fallback) = if let Some(colon_idx) = var_spec.find(':') {
266            (&var_spec[..colon_idx], Some(&var_spec[colon_idx + 1..]))
267        } else {
268            (var_spec, None)
269        };
270
271        let value = std::env::var(var_name).unwrap_or_else(|_| {
272            fallback.unwrap_or("").to_string()
273        });
274
275        let full_pattern = format!("$ENV:{}", var_spec);
276        result = result.replacen(&full_pattern, &value, 1);
277        start = abs_idx + value.len();
278    }
279
280    result
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn leaf_widget() -> WidgetConfigType {
288        WidgetConfigType {
289            block_def: super::super::types::BlockDef {
290                meta: std::collections::HashMap::from([("view".to_string(), serde_json::json!("browser"))]),
291                ..Default::default()
292            },
293            ..Default::default()
294        }
295    }
296
297    fn parent_widget(children: &[&str]) -> WidgetConfigType {
298        WidgetConfigType {
299            children: children.iter().map(|s| s.to_string()).collect(),
300            ..Default::default()
301        }
302    }
303
304    // validate_widget_configs only logs (eprintln) — these tests just confirm
305    // it doesn't panic for every combination, since there's no return value
306    // to assert on. The real coverage of "parent xor leaf" is enforced by the
307    // embedded widgets.json actually loading without triggering a warning,
308    // which build_default_config's own tests below check indirectly by
309    // asserting on the real parsed shape.
310    /// Pane Tab contract Phase 6: a third-party widget's `module` must survive
311    /// parsing (serde drops unknown fields) and reach the frontend unchanged.
312    #[test]
313    fn test_widget_module_survives_parse_and_serialize() {
314        let raw = r#"{"label":"Hello","module":"hello/index.js","blockdef":{"meta":{"view":"ext:hello"}}}"#;
315        let widget: WidgetConfigType = serde_json::from_str(raw).expect("parses");
316        assert_eq!(widget.module, "hello/index.js");
317        let back = serde_json::to_value(&widget).expect("serializes");
318        assert_eq!(back["module"], "hello/index.js");
319        // A built-in widget without one serializes without the key.
320        let plain = serde_json::to_value(leaf_widget()).expect("serializes");
321        assert!(plain.get("module").is_none());
322    }
323
324    #[test]
325    fn test_validate_widget_configs_does_not_panic() {
326        let widgets: HashMap<String, WidgetConfigType> = HashMap::from([
327            ("defwidget@leaf".to_string(), leaf_widget()),
328            ("defwidget@parent".to_string(), parent_widget(&["leaf"])),
329            ("defwidget@neither".to_string(), WidgetConfigType::default()),
330            (
331                "defwidget@both".to_string(),
332                WidgetConfigType {
333                    children: vec!["leaf".to_string()],
334                    ..leaf_widget()
335                },
336            ),
337        ]);
338        validate_widget_configs(&widgets);
339    }
340
341    #[test]
342    fn test_build_default_config_loads_messengers_group() {
343        let config = build_default_config();
344        let messengers = config
345            .widgets
346            .get("defwidget@messengers")
347            .expect("defwidget@messengers should be in the embedded widgets.json");
348        assert_eq!(
349            messengers.children,
350            vec!["discord", "slack", "telegram", "whatsapp", "teams"]
351        );
352        assert!(
353            messengers.block_def.meta.is_empty(),
354            "a parent widget shouldn't carry a blockdef"
355        );
356
357        for short_name in ["discord", "slack", "telegram", "whatsapp", "teams"] {
358            let key = format!("defwidget@{short_name}");
359            let child = config
360                .widgets
361                .get(&key)
362                .unwrap_or_else(|| panic!("{key} should still be in widgets.json"));
363            assert!(child.display_grouponly, "{key} should be display:grouponly now that it's grouped");
364            assert!(child.children.is_empty(), "{key} is a leaf, not itself a parent");
365            assert!(!child.block_def.meta.is_empty(), "{key} should still carry its own blockdef");
366        }
367    }
368
369    #[test]
370    fn a_widget_saved_with_the_old_display_hidden_name_still_reads_as_group_only() {
371        let old: WidgetConfigType = serde_json::from_str(r#"{ "display:hidden": true }"#).unwrap();
372        assert!(old.display_grouponly);
373        let new: WidgetConfigType = serde_json::from_str(r#"{ "display:grouponly": true }"#).unwrap();
374        assert!(new.display_grouponly);
375        // Written back under the new name.
376        assert_eq!(serde_json::to_value(&old).unwrap()["display:grouponly"], true);
377    }
378}