1mod loader;
11pub mod types;
12mod watcher;
13
14pub use loader::*;
16pub use types::*;
17pub use watcher::*;
18
19pub 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#[cfg(test)]
31mod tests {
32 use super::*;
33 use std::path::PathBuf;
34
35 #[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 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 #[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 #[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 #[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 #[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 #[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 #[test]
276 fn test_full_config_default() {
277 let config = FullConfigType::default();
278 let json = serde_json::to_string(&config).unwrap();
279 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 #[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 #[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 #[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 let _ = watcher.get_settings();
452 }
453
454 #[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 #[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()); 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 #[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 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 assert!(result.contains(" \"window:blur\": true,"));
591 assert!(result.contains("// \"term:fontsize\""));
593 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 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}