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