agentmux_srv\backend\wconfig/
mod.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Configuration system: settings, themes, widgets, bookmarks, connections.
5//! Port of Go's pkg/wconfig/.
6//!
7//! Provides the full configuration type hierarchy and a thread-safe
8//! config watcher.
9
10mod loader;
11pub mod types;
12mod watcher;
13
14// Re-export all public APIs so callers can continue using `wconfig::Type`.
15pub use loader::*;
16pub use types::*;
17pub use watcher::*;
18
19// ---- Config file constants ----
20
21pub const SETTINGS_FILE: &str = "settings.json";
22pub const SETTINGS_TEMPLATE: &str = include_str!("../../../../settings-template.jsonc");
23#[allow(dead_code)]
24pub const CONNECTIONS_FILE: &str = "connections.json";
25#[allow(dead_code)]
26pub const PROFILES_FILE: &str = "profiles.json";
27
28// ---- Tests ----
29
30#[cfg(test)]
31mod tests {
32    use super::*;
33    use std::path::PathBuf;
34
35    // -- SettingsType serde --
36
37    #[test]
38    fn test_settings_default_empty() {
39        let s = SettingsType::default();
40        let json = serde_json::to_string(&s).unwrap();
41        assert_eq!(json, "{}");
42    }
43
44    #[test]
45    fn test_settings_terminal_fields() {
46        let s = SettingsType {
47            term_font_size: 14.0,
48            term_theme: "dracula".to_string(),
49            term_scrollback: Some(10000),
50            term_copy_on_select: Some(true),
51            ..Default::default()
52        };
53        let json = serde_json::to_string(&s).unwrap();
54        assert!(json.contains("\"term:fontsize\":14.0"));
55        assert!(json.contains("\"term:theme\":\"dracula\""));
56        assert!(json.contains("\"term:scrollback\":10000"));
57        assert!(json.contains("\"term:copyonselect\":true"));
58    }
59
60    #[test]
61    fn test_settings_window_fields() {
62        let s = SettingsType {
63            window_transparent: true,
64            window_opacity: Some(0.9),
65            window_zoom: Some(1.5),
66            window_dimensions: "1920x1080".to_string(),
67            ..Default::default()
68        };
69        let json = serde_json::to_string(&s).unwrap();
70        assert!(json.contains("\"window:transparent\":true"));
71        assert!(json.contains("\"window:opacity\":0.9"));
72        assert!(json.contains("\"window:zoom\":1.5"));
73        assert!(json.contains("\"window:dimensions\":\"1920x1080\""));
74    }
75
76    #[test]
77    fn test_settings_from_json() {
78        let json_str = r#"{
79            "term:fontsize": 13,
80            "term:theme": "solarized-dark",
81            "term:scrollback": 5000,
82            "window:transparent": true,
83            "window:opacity": 0.85,
84            "telemetry:enabled": true
85        }"#;
86        let s: SettingsType = serde_json::from_str(json_str).unwrap();
87        assert_eq!(s.term_font_size, 13.0);
88        assert_eq!(s.term_theme, "solarized-dark");
89        assert_eq!(s.term_scrollback, Some(5000));
90        assert!(s.window_transparent);
91        assert_eq!(s.window_opacity, Some(0.85));
92        assert!(s.telemetry_enabled);
93    }
94
95    #[test]
96    fn test_settings_roundtrip() {
97        let s = SettingsType {
98            term_font_size: 14.0,
99            term_theme: "dracula".to_string(),
100            window_opacity: Some(0.95),
101            ..Default::default()
102        };
103        let json = serde_json::to_string(&s).unwrap();
104        let parsed: SettingsType = serde_json::from_str(&json).unwrap();
105        assert_eq!(parsed.term_font_size, 14.0);
106        assert_eq!(parsed.term_theme, "dracula");
107        assert_eq!(parsed.window_opacity, Some(0.95));
108    }
109
110    #[test]
111    fn test_settings_unknown_keys_passthrough() {
112        // Legacy keys (ai:*, autoupdate:*, editor:*, markdown:*) removed from
113        // SettingsType should land in `extra` via the flatten catch-all so
114        // existing user settings.json files don't error.
115        let json_str = r#"{
116            "ai:model": "claude-3-opus",
117            "autoupdate:enabled": true,
118            "editor:wordwrap": true,
119            "term:fontsize": 14
120        }"#;
121        let s: SettingsType = serde_json::from_str(json_str).unwrap();
122        assert_eq!(s.term_font_size, 14.0);
123        assert!(s.extra.contains_key("ai:model"));
124        assert!(s.extra.contains_key("autoupdate:enabled"));
125        assert!(s.extra.contains_key("editor:wordwrap"));
126    }
127
128    // -- TermThemeType serde --
129
130    #[test]
131    fn test_term_theme_serde() {
132        let theme = TermThemeType {
133            display_name: "Dracula".to_string(),
134            black: "#282a36".to_string(),
135            red: "#ff5555".to_string(),
136            foreground: "#f8f8f2".to_string(),
137            background: "#282a36".to_string(),
138            cursor: "#f8f8f2".to_string(),
139            bright_red: "#ff6e6e".to_string(),
140            ..Default::default()
141        };
142        let json = serde_json::to_string(&theme).unwrap();
143        assert!(json.contains(r#""display:name":"Dracula""#));
144        assert!(json.contains(r##""brightRed":"#ff6e6e""##));
145        assert!(json.contains(r##""foreground":"#f8f8f2""##));
146
147        let parsed: TermThemeType = serde_json::from_str(&json).unwrap();
148        assert_eq!(parsed.display_name, "Dracula");
149        assert_eq!(parsed.bright_red, "#ff6e6e");
150    }
151
152    #[test]
153    fn test_term_theme_from_go_json() {
154        let go_json = r##"{
155            "display:name": "Solarized Dark",
156            "display:order": 1.0,
157            "black": "#073642",
158            "red": "#dc322f",
159            "green": "#859900",
160            "yellow": "#b58900",
161            "blue": "#268bd2",
162            "magenta": "#d33682",
163            "cyan": "#2aa198",
164            "white": "#eee8d5",
165            "brightBlack": "#002b36",
166            "brightRed": "#cb4b16",
167            "brightGreen": "#586e75",
168            "brightYellow": "#657b83",
169            "brightBlue": "#839496",
170            "brightMagenta": "#6c71c4",
171            "brightCyan": "#93a1a1",
172            "brightWhite": "#fdf6e3",
173            "foreground": "#839496",
174            "background": "#002b36",
175            "cursor": "#839496",
176            "selectionBackground": "#073642"
177        }"##;
178        let parsed: TermThemeType = serde_json::from_str(go_json).unwrap();
179        assert_eq!(parsed.display_name, "Solarized Dark");
180        assert_eq!(parsed.display_order, 1.0);
181        assert_eq!(parsed.black, "#073642");
182        assert_eq!(parsed.bright_cyan, "#93a1a1");
183        assert_eq!(parsed.selection_background, "#073642");
184    }
185
186    // -- WebBookmark serde --
187
188    #[test]
189    fn test_web_bookmark_serde() {
190        let bm = WebBookmark {
191            url: "https://example.com".to_string(),
192            title: "Example".to_string(),
193            icon_url: "https://example.com/favicon.ico".to_string(),
194            display_order: 1.0,
195            ..Default::default()
196        };
197        let json = serde_json::to_string(&bm).unwrap();
198        assert!(json.contains("\"iconurl\":\"https://example.com/favicon.ico\""));
199        assert!(json.contains("\"display:order\":1.0"));
200
201        let parsed: WebBookmark = serde_json::from_str(&json).unwrap();
202        assert_eq!(parsed.url, "https://example.com");
203    }
204
205    // -- ConnKeywords serde --
206
207    #[test]
208    fn test_conn_keywords_serde() {
209        let kw = ConnKeywords {
210            ssh_user: Some("admin".to_string()),
211            ssh_hostname: Some("server.example.com".to_string()),
212            ssh_port: Some("2222".to_string()),
213            ssh_identity_file: vec!["~/.ssh/id_rsa".to_string()],
214            ..Default::default()
215        };
216        let json = serde_json::to_string(&kw).unwrap();
217        assert!(json.contains("\"ssh:user\":\"admin\""));
218        assert!(json.contains("\"ssh:hostname\":\"server.example.com\""));
219        assert!(json.contains("\"ssh:port\":\"2222\""));
220        assert!(json.contains("\"ssh:identityfile\":[\"~/.ssh/id_rsa\"]"));
221    }
222
223    #[test]
224    fn test_conn_keywords_from_go_json() {
225        let go_json = r#"{
226            "ssh:user": "deploy",
227            "ssh:hostname": "prod.example.com",
228            "ssh:port": "22",
229            "ssh:identityfile": ["~/.ssh/deploy_key", "~/.ssh/id_ed25519"],
230            "ssh:pubkeyauthentication": true,
231            "ssh:proxyjump": ["bastion.example.com"],
232            "term:theme": "monokai",
233            "cmd:env": {"NODE_ENV": "production"}
234        }"#;
235        let parsed: ConnKeywords = serde_json::from_str(go_json).unwrap();
236        assert_eq!(parsed.ssh_user, Some("deploy".to_string()));
237        assert_eq!(parsed.ssh_identity_file.len(), 2);
238        assert_eq!(parsed.ssh_pubkey_authentication, Some(true));
239        assert_eq!(parsed.ssh_proxy_jump, vec!["bastion.example.com"]);
240        assert_eq!(parsed.term_theme, "monokai");
241        assert_eq!(parsed.cmd_env.get("NODE_ENV").unwrap(), "production");
242    }
243
244    // -- WidgetConfigType serde --
245
246    #[test]
247    fn test_widget_config_serde() {
248        let w = WidgetConfigType {
249            display_order: 1.5,
250            icon: "terminal".to_string(),
251            label: "Shell".to_string(),
252            description: "Terminal emulator".to_string(),
253            ..Default::default()
254        };
255        let json = serde_json::to_string(&w).unwrap();
256        assert!(json.contains("\"display:order\":1.5"));
257        assert!(json.contains("\"label\":\"Shell\""));
258    }
259
260    // -- MimeTypeConfigType serde --
261
262    #[test]
263    fn test_mime_type_config_serde() {
264        let mt = MimeTypeConfigType {
265            icon: "file-code".to_string(),
266            color: "#e06c75".to_string(),
267        };
268        let json = serde_json::to_string(&mt).unwrap();
269        assert!(json.contains("\"icon\":\"file-code\""));
270        assert!(json.contains(r##""color":"#e06c75""##));
271    }
272
273    // -- FullConfigType serde --
274
275    #[test]
276    fn test_full_config_default() {
277        let config = FullConfigType::default();
278        let json = serde_json::to_string(&config).unwrap();
279        // Should have all top-level keys even if empty
280        assert!(json.contains("\"settings\""));
281        assert!(json.contains("\"mimetypes\""));
282        assert!(json.contains("\"termthemes\""));
283    }
284
285    #[test]
286    fn test_full_config_with_data() {
287        let mut config = FullConfigType::default();
288        config.settings.term_theme = "dracula".to_string();
289        config.term_themes.insert(
290            "test".to_string(),
291            TermThemeType {
292                display_name: "Test Theme".to_string(),
293                ..Default::default()
294            },
295        );
296        config.bookmarks.insert(
297            "example".to_string(),
298            WebBookmark {
299                url: "https://example.com".to_string(),
300                ..Default::default()
301            },
302        );
303
304        let json = serde_json::to_string(&config).unwrap();
305        let parsed: FullConfigType = serde_json::from_str(&json).unwrap();
306        assert_eq!(parsed.settings.term_theme, "dracula");
307        assert_eq!(parsed.term_themes.len(), 1);
308        assert_eq!(
309            parsed.term_themes.get("test").unwrap().display_name,
310            "Test Theme"
311        );
312        assert_eq!(parsed.bookmarks.len(), 1);
313    }
314
315    #[test]
316    fn test_full_config_from_json() {
317        let json = r##"{
318            "settings": {
319                "term:theme": "solarized-dark",
320                "term:fontsize": 14,
321                "window:transparent": true
322            },
323            "mimetypes": {
324                "text/rust": {"icon": "rust", "color": "#dea584"}
325            },
326            "termthemes": {
327                "dracula": {
328                    "display:name": "Dracula",
329                    "black": "#282a36",
330                    "foreground": "#f8f8f2"
331                }
332            },
333            "bookmarks": {
334                "docs": {"url": "https://docs.rs", "title": "Rust Docs"}
335            },
336            "connections": {
337                "prod-server": {
338                    "ssh:user": "admin",
339                    "ssh:hostname": "prod.example.com"
340                }
341            },
342            "widgets": {},
343            "defaultwidgets": {},
344            "presets": {}
345        }"##;
346        let parsed: FullConfigType = serde_json::from_str(json).unwrap();
347        assert_eq!(parsed.settings.term_theme, "solarized-dark");
348        assert_eq!(parsed.settings.term_font_size, 14.0);
349        assert!(parsed.settings.window_transparent);
350        assert_eq!(parsed.mime_types.len(), 1);
351        assert_eq!(parsed.term_themes.get("dracula").unwrap().display_name, "Dracula");
352        assert_eq!(parsed.bookmarks.get("docs").unwrap().title, "Rust Docs");
353        assert_eq!(
354            parsed.connections.get("prod-server").unwrap().ssh_user,
355            Some("admin".to_string())
356        );
357    }
358
359    // -- ConfigError serde --
360
361    #[test]
362    fn test_config_error_serde() {
363        let err = ConfigError {
364            file: "settings.json".to_string(),
365            err: "unexpected token at line 5".to_string(),
366        };
367        let json = serde_json::to_string(&err).unwrap();
368        assert!(json.contains(r#""file":"settings.json""#));
369        assert!(json.contains(r#""err":"unexpected token at line 5""#));
370    }
371
372    // -- WebhookConfigType serde --
373
374    #[test]
375    fn test_webhook_config_serde() {
376        let wh = WebhookConfigType {
377            version: "1".to_string(),
378            workspace_id: "ws-123".to_string(),
379            auth_token: "tok-abc".to_string(),
380            cloud_endpoint: "wss://cloud.example.com".to_string(),
381            enabled: true,
382            terminals: vec!["term-1".to_string()],
383        };
384        let json = serde_json::to_string(&wh).unwrap();
385        assert!(json.contains("\"workspaceId\":\"ws-123\""));
386        assert!(json.contains("\"authToken\":\"tok-abc\""));
387        assert!(json.contains("\"cloudEndpoint\":\"wss://cloud.example.com\""));
388    }
389
390    // -- ConfigWatcher --
391
392    #[test]
393    fn test_config_watcher_default() {
394        let watcher = ConfigWatcher::new();
395        let config = watcher.get_full_config();
396        assert!(config.settings.term_theme.is_empty());
397    }
398
399    #[test]
400    fn test_config_watcher_with_initial() {
401        let mut config = FullConfigType::default();
402        config.settings.term_theme = "dracula".to_string();
403        let watcher = ConfigWatcher::with_config(config);
404        assert_eq!(watcher.get_settings().term_theme, "dracula");
405    }
406
407    #[test]
408    fn test_config_watcher_set_config() {
409        let watcher = ConfigWatcher::new();
410        let mut config = FullConfigType::default();
411        config.settings.term_font_size = 16.0;
412        watcher.set_config(config);
413        assert_eq!(watcher.get_settings().term_font_size, 16.0);
414    }
415
416    #[test]
417    fn test_config_watcher_update_settings() {
418        let watcher = ConfigWatcher::new();
419        let settings = SettingsType {
420            term_theme: "solarized-dark".to_string(),
421            ..Default::default()
422        };
423        watcher.update_settings(settings);
424        assert_eq!(watcher.get_settings().term_theme, "solarized-dark");
425    }
426
427    #[test]
428    fn test_config_watcher_thread_safety() {
429        use std::sync::Arc;
430        use std::thread;
431
432        let watcher = Arc::new(ConfigWatcher::new());
433        let handles: Vec<_> = (0..4)
434            .map(|i| {
435                let w = watcher.clone();
436                thread::spawn(move || {
437                    let s = SettingsType {
438                        term_theme: format!("theme-{}", i),
439                        ..Default::default()
440                    };
441                    w.update_settings(s);
442                    let _ = w.get_full_config();
443                })
444            })
445            .collect();
446
447        for h in handles {
448            h.join().unwrap();
449        }
450        // Should not panic — proves thread safety
451        let _ = watcher.get_settings();
452    }
453
454    // -- expand_env_vars --
455
456    #[test]
457    fn test_expand_env_vars_no_vars() {
458        assert_eq!(expand_env_vars("hello world"), "hello world");
459    }
460
461    #[test]
462    fn test_expand_env_vars_with_var() {
463        std::env::set_var("TEST_WCONFIG_VAR", "replaced");
464        let result = expand_env_vars("prefix $ENV:TEST_WCONFIG_VAR suffix");
465        assert_eq!(result, "prefix replaced suffix");
466        std::env::remove_var("TEST_WCONFIG_VAR");
467    }
468
469    #[test]
470    fn test_expand_env_vars_with_fallback() {
471        let result = expand_env_vars("$ENV:NONEXISTENT_VAR_12345:fallback_value");
472        assert_eq!(result, "fallback_value");
473    }
474
475    #[test]
476    fn test_expand_env_vars_missing_no_fallback() {
477        let result = expand_env_vars("$ENV:NONEXISTENT_VAR_99999");
478        assert_eq!(result, "");
479    }
480
481    // -- read_config_file --
482
483    #[test]
484    fn test_read_config_file_missing() {
485        let path = PathBuf::from("/nonexistent/settings.json");
486        let (config, errors): (SettingsType, _) = read_config_file(&path);
487        assert!(errors.is_empty()); // Missing file is not an error
488        assert!(config.term_theme.is_empty());
489    }
490
491    #[test]
492    fn test_read_config_file_with_comments() {
493        let dir = std::env::temp_dir().join("agentmux_test_jsonc");
494        std::fs::create_dir_all(&dir).unwrap();
495        let path = dir.join("settings_comments.json");
496        std::fs::write(
497            &path,
498            r#"
499// Terminal settings
500{
501    "term:fontsize": 16.0,
502    /* theme */
503    "term:theme": "dracula" // inline comment
504}
505"#,
506        )
507        .unwrap();
508
509        let (config, errors): (SettingsType, _) = read_config_file(&path);
510        assert!(errors.is_empty(), "errors: {:?}", errors);
511        assert_eq!(config.term_font_size, 16.0);
512        assert_eq!(config.term_theme, "dracula");
513
514        std::fs::remove_dir_all(&dir).ok();
515    }
516
517    #[test]
518    fn test_read_config_file_trailing_commas() {
519        let dir = std::env::temp_dir().join("agentmux_test_trailing");
520        std::fs::create_dir_all(&dir).unwrap();
521        let path = dir.join("settings_trailing.json");
522        std::fs::write(
523            &path,
524            r#"{
525    // "term:fontsize": 12,
526     "window:opacity": 0.7,
527    // "window:bgcolor": ""
528}"#,
529        )
530        .unwrap();
531
532        let (config, errors): (SettingsType, _) = read_config_file(&path);
533        assert!(errors.is_empty(), "trailing comma should be tolerated: {:?}", errors);
534        assert_eq!(config.window_opacity, Some(0.7));
535
536        std::fs::remove_dir_all(&dir).ok();
537    }
538
539    #[test]
540    fn test_strip_trailing_commas_basic() {
541        assert_eq!(loader::strip_trailing_commas(r#"{"a": 1,}"#), r#"{"a": 1 }"#);
542        assert_eq!(loader::strip_trailing_commas(r#"[1, 2,]"#), r#"[1, 2 ]"#);
543        assert_eq!(loader::strip_trailing_commas(r#"{"a": "b,}"}"#), r#"{"a": "b,}"}"#);
544        assert_eq!(loader::strip_trailing_commas(r#"{"a": 1, "b": 2}"#), r#"{"a": 1, "b": 2}"#);
545    }
546
547    // -- merge_into_template --
548
549    #[test]
550    fn test_merge_into_template_empty_settings() {
551        let template = "// header\n{\n    // \"foo:bar\": 1,\n}\n";
552        let settings = serde_json::Map::new();
553        let result = merge_into_template(template, &settings);
554        assert_eq!(result, template);
555    }
556
557    #[test]
558    fn test_merge_into_template_uncomments_known_key() {
559        let template = "{\n    // \"window:transparent\":       false,\n}\n";
560        let mut settings = serde_json::Map::new();
561        settings.insert("window:transparent".to_string(), serde_json::Value::Bool(true));
562        let result = merge_into_template(template, &settings);
563        assert!(result.contains("    \"window:transparent\": true,"));
564        assert!(!result.contains("//"));
565    }
566
567    #[test]
568    fn test_merge_into_template_appends_unknown_key() {
569        let template = "{\n    // \"term:fontsize\":            12,\n}\n";
570        let mut settings = serde_json::Map::new();
571        settings.insert(
572            "widget:order".to_string(),
573            serde_json::json!(["agent", "settings"]),
574        );
575        let result = merge_into_template(template, &settings);
576        assert!(result.contains("// -- User Overrides --"));
577        assert!(result.contains("\"widget:order\": [\"agent\",\"settings\"]"));
578        // Template line should still be commented
579        assert!(result.contains("// \"term:fontsize\""));
580    }
581
582    #[test]
583    fn test_merge_into_template_mixed() {
584        let template = "{\n    // \"window:blur\":              false,\n    // \"term:fontsize\":            12,\n}\n";
585        let mut settings = serde_json::Map::new();
586        settings.insert("window:blur".to_string(), serde_json::Value::Bool(true));
587        settings.insert("custom:key".to_string(), serde_json::json!("hello"));
588        let result = merge_into_template(template, &settings);
589        // Known key uncommented
590        assert!(result.contains("    \"window:blur\": true,"));
591        // Other known key still commented
592        assert!(result.contains("// \"term:fontsize\""));
593        // Unknown key appended
594        assert!(result.contains("\"custom:key\": \"hello\""));
595    }
596
597    #[test]
598    fn test_merge_into_template_idempotent() {
599        let template = SETTINGS_TEMPLATE;
600        let mut settings = serde_json::Map::new();
601        settings.insert("window:transparent".to_string(), serde_json::Value::Bool(true));
602        settings.insert("widget:order".to_string(), serde_json::json!(["a", "b"]));
603
604        let first = merge_into_template(template, &settings);
605        // Parse the result back and merge again
606        let parsed = parse_jsonc_to_map(&first);
607        let second = merge_into_template(template, &parsed);
608        assert_eq!(first, second, "merge_into_template should be idempotent");
609    }
610
611    #[test]
612    fn test_merge_into_template_preserves_indentation() {
613        let template = "{\n        // \"deep:key\":   42,\n}\n";
614        let mut settings = serde_json::Map::new();
615        settings.insert("deep:key".to_string(), serde_json::json!(99));
616        let result = merge_into_template(template, &settings);
617        assert!(result.contains("        \"deep:key\": 99,"));
618    }
619
620    #[test]
621    fn test_parse_jsonc_to_map() {
622        let content = r#"// comment
623{
624    // "commented": true,
625    "active": 42,
626    "name": "test",
627}
628"#;
629        let map = parse_jsonc_to_map(content);
630        assert_eq!(map.get("active"), Some(&serde_json::json!(42)));
631        assert_eq!(map.get("name"), Some(&serde_json::json!("test")));
632        assert!(map.get("commented").is_none());
633    }
634}