1use std::io;
2use std::path::PathBuf;
3
4use log::debug;
5
6use crate::app::{ContainersSortMode, SortMode, ViewMode};
7use crate::fs_util;
8use crate::runtime::env::Paths;
9
10#[cfg(test)]
15pub(crate) use crate::demo_flag::GLOBAL_TEST_LOCK as GLOBAL_TEST_IO_LOCK;
16
17fn prefs_file(paths: Option<&Paths>) -> Option<PathBuf> {
22 paths.map(Paths::preferences)
23}
24
25fn load_value(paths: Option<&Paths>, key: &str) -> Option<String> {
27 let path = prefs_file(paths)?;
28 let content = match std::fs::read_to_string(&path) {
29 Ok(c) => c,
30 Err(e) => {
31 if e.kind() != std::io::ErrorKind::NotFound {
32 debug!("[config] Failed to read preferences file: {e}");
33 }
34 return None;
35 }
36 };
37 for line in content.lines() {
38 let line = line.trim();
39 if line.starts_with('#') || line.is_empty() {
40 continue;
41 }
42 if let Some((k, v)) = line.split_once('=')
43 && k.trim() == key
44 {
45 return Some(v.trim().to_string());
46 }
47 }
48 None
49}
50
51fn save_value(paths: Option<&Paths>, key: &str, value: &str) -> io::Result<()> {
53 let path = match prefs_file(paths) {
54 Some(p) => p,
55 None => return Ok(()),
56 };
57 #[cfg(not(test))]
63 if crate::demo_flag::is_demo() {
64 return Ok(());
65 }
66
67 let existing = std::fs::read_to_string(&path).unwrap_or_default();
68 let mut lines: Vec<String> = Vec::new();
69 let mut found = false;
70
71 for line in existing.lines() {
72 let trimmed = line.trim();
73 if !trimmed.starts_with('#')
74 && !trimmed.is_empty()
75 && trimmed
76 .split_once('=')
77 .is_some_and(|(k, _)| k.trim() == key)
78 {
79 lines.push(format!("{}={}", key, value));
80 found = true;
81 } else {
82 lines.push(line.to_string());
83 }
84 }
85
86 if !found {
87 lines.push(format!("{}={}", key, value));
88 }
89
90 let content = lines.join("\n") + "\n";
91
92 fs_util::atomic_write(&path, content.as_bytes())
93}
94
95pub fn load_sort_mode(paths: Option<&Paths>) -> SortMode {
97 load_value(paths, "sort_mode")
98 .map(|v| SortMode::from_key(&v))
99 .unwrap_or(SortMode::MostRecent)
100}
101
102pub fn save_sort_mode(paths: Option<&Paths>, mode: SortMode) -> io::Result<()> {
104 log::debug!("[purple] saving sort_mode={}", mode.to_key());
105 save_value(paths, "sort_mode", mode.to_key()).inspect_err(|e| {
106 log::warn!("[config] failed to save sort_mode={}: {}", mode.to_key(), e);
107 })
108}
109
110pub fn load_group_by(paths: Option<&Paths>) -> crate::app::GroupBy {
114 use crate::app::GroupBy;
115 if let Some(v) = load_value(paths, "group_by") {
116 return GroupBy::from_key(&v);
117 }
118 if let Some(v) = load_value(paths, "group_by_provider") {
119 return if v == "true" {
120 GroupBy::Provider
121 } else {
122 GroupBy::None
123 };
124 }
125 GroupBy::Provider
126}
127
128fn remove_value(paths: Option<&Paths>, key: &str) -> io::Result<()> {
130 let path = match prefs_file(paths) {
131 Some(p) => p,
132 None => return Ok(()),
133 };
134 #[cfg(not(test))]
138 if crate::demo_flag::is_demo() {
139 return Ok(());
140 }
141 let existing = std::fs::read_to_string(&path).unwrap_or_default();
142
143 let has_key = existing.lines().any(|line| {
145 let trimmed = line.trim();
146 !trimmed.starts_with('#')
147 && !trimmed.is_empty()
148 && trimmed
149 .split_once('=')
150 .is_some_and(|(k, _)| k.trim() == key)
151 });
152 if !has_key {
153 return Ok(());
154 }
155
156 let lines: Vec<String> = existing
157 .lines()
158 .filter(|line| {
159 let trimmed = line.trim();
160 if trimmed.starts_with('#') || trimmed.is_empty() {
161 return true;
162 }
163 trimmed.split_once('=').is_none_or(|(k, _)| k.trim() != key)
164 })
165 .map(|l| l.to_string())
166 .collect();
167 let content = lines.join("\n") + "\n";
168 fs_util::atomic_write(&path, content.as_bytes())
169}
170
171pub fn save_group_by(paths: Option<&Paths>, mode: &crate::app::GroupBy) -> io::Result<()> {
173 log::debug!("[purple] saving group_by={}", mode.to_key());
174 save_value(paths, "group_by", &mode.to_key()).inspect_err(|e| {
175 log::warn!("[config] failed to save group_by={}: {}", mode.to_key(), e);
176 })?;
177 let _ = remove_value(paths, "group_by_provider");
180 Ok(())
181}
182
183pub fn load_view_mode(paths: Option<&Paths>) -> ViewMode {
185 load_value(paths, "view_mode")
186 .map(|v| match v.as_str() {
187 "compact" => ViewMode::Compact,
188 _ => ViewMode::Detailed,
189 })
190 .unwrap_or(ViewMode::Detailed)
191}
192
193pub fn save_view_mode(paths: Option<&Paths>, mode: ViewMode) -> io::Result<()> {
195 let value = match mode {
196 ViewMode::Compact => "compact",
197 ViewMode::Detailed => "detailed",
198 };
199 log::debug!("[purple] saving view_mode={}", value);
200 save_value(paths, "view_mode", value).inspect_err(|e| {
201 log::warn!("[config] failed to save view_mode={}: {}", value, e);
202 })
203}
204
205pub fn load_containers_sort_mode(paths: Option<&Paths>) -> ContainersSortMode {
209 load_value(paths, "containers_sort_mode")
210 .map(|v| ContainersSortMode::from_key(&v))
211 .unwrap_or_default()
212}
213
214pub fn save_containers_sort_mode(
215 paths: Option<&Paths>,
216 mode: ContainersSortMode,
217) -> io::Result<()> {
218 log::debug!("[purple] saving containers_sort_mode={}", mode.to_key());
219 save_value(paths, "containers_sort_mode", mode.to_key()).inspect_err(|e| {
220 log::warn!(
221 "[config] failed to save containers_sort_mode={}: {}",
222 mode.to_key(),
223 e
224 );
225 })
226}
227
228pub fn load_containers_view_mode(paths: Option<&Paths>) -> ViewMode {
233 load_value(paths, "containers_view_mode")
234 .map(|v| match v.as_str() {
235 "compact" => ViewMode::Compact,
236 _ => ViewMode::Detailed,
237 })
238 .unwrap_or(ViewMode::Detailed)
239}
240
241pub fn save_containers_view_mode(paths: Option<&Paths>, mode: ViewMode) -> io::Result<()> {
242 let value = match mode {
243 ViewMode::Compact => "compact",
244 ViewMode::Detailed => "detailed",
245 };
246 log::debug!("[purple] saving containers_view_mode={}", value);
247 save_value(paths, "containers_view_mode", value).inspect_err(|e| {
248 log::warn!(
249 "[config] failed to save containers_view_mode={}: {}",
250 value,
251 e
252 );
253 })
254}
255
256pub fn load_containers_collapsed_hosts(paths: Option<&Paths>) -> std::collections::HashSet<String> {
261 load_value(paths, "containers_collapsed_hosts")
262 .map(|raw| {
263 raw.split(',')
264 .map(|s| s.trim().to_string())
265 .filter(|s| !s.is_empty())
266 .collect()
267 })
268 .unwrap_or_default()
269}
270
271pub fn save_containers_collapsed_hosts(
272 paths: Option<&Paths>,
273 aliases: &std::collections::HashSet<String>,
274) -> io::Result<()> {
275 if aliases.is_empty() {
276 log::debug!("[purple] clearing containers_collapsed_hosts");
277 let _ = remove_value(paths, "containers_collapsed_hosts");
278 return Ok(());
279 }
280 let mut sorted: Vec<&str> = aliases.iter().map(|s| s.as_str()).collect();
281 sorted.sort_unstable();
282 let joined = sorted.join(",");
283 log::debug!(
284 "[purple] saving containers_collapsed_hosts={} ({} aliases)",
285 joined,
286 sorted.len()
287 );
288 save_value(paths, "containers_collapsed_hosts", &joined).inspect_err(|e| {
289 log::warn!("[config] failed to save containers_collapsed_hosts: {}", e);
290 })
291}
292
293pub fn load_askpass_default(paths: Option<&Paths>) -> Option<String> {
295 load_value(paths, "askpass").filter(|v| !v.is_empty())
296}
297
298pub fn save_askpass_default(paths: Option<&Paths>, source: &str) -> io::Result<()> {
300 log::debug!("[purple] saving askpass default={}", source);
301 save_value(paths, "askpass", source).inspect_err(|e| {
302 log::warn!("[config] failed to save askpass={}: {}", source, e);
303 })
304}
305
306pub fn load_slow_threshold(paths: Option<&Paths>) -> u16 {
308 load_value(paths, "slow_threshold_ms")
309 .and_then(|v| v.parse().ok())
310 .unwrap_or(200)
311}
312
313#[allow(dead_code)]
315pub fn save_slow_threshold(paths: Option<&Paths>, ms: u16) -> io::Result<()> {
316 log::debug!("[purple] saving slow_threshold_ms={}", ms);
317 save_value(paths, "slow_threshold_ms", &ms.to_string()).inspect_err(|e| {
318 log::warn!("[config] failed to save slow_threshold_ms={}: {}", ms, e);
319 })
320}
321
322pub fn load_theme(paths: Option<&Paths>) -> Option<String> {
324 load_value(paths, "theme").filter(|v| !v.is_empty())
325}
326
327pub fn save_theme(paths: Option<&Paths>, name: &str) -> io::Result<()> {
329 log::debug!("[purple] saving theme={}", name);
330 save_value(paths, "theme", name).inspect_err(|e| {
331 log::warn!("[config] failed to save theme={}: {}", name, e);
332 })
333}
334
335const LAST_SEEN_VERSION_KEY: &str = "last_seen_version";
336
337pub fn save_last_seen_version(paths: Option<&Paths>, version: &str) -> io::Result<()> {
339 log::debug!("[purple] saving last_seen_version={}", version);
340 save_value(paths, LAST_SEEN_VERSION_KEY, version)
341}
342
343pub fn load_last_seen_version(paths: Option<&Paths>) -> io::Result<Option<String>> {
345 Ok(load_value(paths, LAST_SEEN_VERSION_KEY))
346}
347
348#[cfg(test)]
350pub(crate) mod tests_helpers {
351 pub fn with_temp_prefs<F: FnOnce(&crate::runtime::env::Paths)>(f: F) {
352 let dir = tempfile::tempdir().expect("create temp prefs dir");
353 let paths = crate::runtime::env::Paths::new(dir.path());
354 if let Some(parent) = paths.preferences().parent() {
357 std::fs::create_dir_all(parent).expect("create prefs parent dir");
358 }
359 f(&paths);
360 }
361}
362
363pub fn load_auto_ping(paths: Option<&Paths>) -> bool {
365 load_value(paths, "auto_ping")
366 .map(|v| v != "false")
367 .unwrap_or(true)
368}
369
370#[allow(dead_code)]
372pub fn save_auto_ping(paths: Option<&Paths>, enabled: bool) -> io::Result<()> {
373 let value = if enabled { "true" } else { "false" };
374 log::debug!("[purple] saving auto_ping={}", value);
375 save_value(paths, "auto_ping", value).inspect_err(|e| {
376 log::warn!("[config] failed to save auto_ping={}: {}", value, e);
377 })
378}
379
380pub fn load_ssh_command(paths: Option<&Paths>) -> Option<String> {
383 load_value(paths, crate::ssh_launcher::PREF_KEY).filter(|v| !v.trim().is_empty())
384}
385
386#[allow(dead_code)]
388pub fn save_ssh_command(paths: Option<&Paths>, command: &str) -> io::Result<()> {
389 log::debug!("[purple] saving ssh_command={}", command);
390 save_value(paths, crate::ssh_launcher::PREF_KEY, command).inspect_err(|e| {
391 log::warn!("[config] failed to save ssh_command={}: {}", command, e);
392 })
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 fn parse_value(content: &str, key: &str) -> Option<String> {
403 for line in content.lines() {
404 let line = line.trim();
405 if line.starts_with('#') || line.is_empty() {
406 continue;
407 }
408 if let Some((k, v)) = line.split_once('=')
409 && k.trim() == key
410 {
411 return Some(v.trim().to_string());
412 }
413 }
414 None
415 }
416
417 #[test]
418 fn load_askpass_returns_value() {
419 let content = "askpass=keychain\n";
420 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
421 assert_eq!(val, Some("keychain".to_string()));
422 }
423
424 #[test]
425 fn load_askpass_returns_none_for_empty() {
426 let content = "askpass=\n";
427 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
428 assert_eq!(val, None);
429 }
430
431 #[test]
432 fn load_askpass_returns_none_when_missing() {
433 let content = "sort_mode=alpha\n";
434 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
435 assert_eq!(val, None);
436 }
437
438 #[test]
439 fn load_askpass_preserves_vault_uri() {
440 let content = "askpass=vault:secret/ssh#password\n";
441 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
442 assert_eq!(val, Some("vault:secret/ssh#password".to_string()));
443 }
444
445 #[test]
446 fn load_askpass_preserves_op_uri() {
447 let content = "askpass=op://Vault/SSH/password\n";
448 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
449 assert_eq!(val, Some("op://Vault/SSH/password".to_string()));
450 }
451
452 #[test]
453 fn load_askpass_among_other_prefs() {
454 let content = "sort_mode=alpha\ngroup_by_provider=true\naskpass=bw:my-item\n";
455 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
456 assert_eq!(val, Some("bw:my-item".to_string()));
457 }
458
459 #[test]
460 fn save_value_builds_correct_line() {
461 let key = "askpass";
463 let value = "keychain";
464 let line = format!("{}={}", key, value);
465 assert_eq!(line, "askpass=keychain");
466 }
467
468 #[test]
469 fn save_value_replaces_existing() {
470 let existing = "sort_mode=alpha\naskpass=old\n";
472 let key = "askpass";
473 let new_value = "vault:secret/ssh";
474
475 let mut lines: Vec<String> = Vec::new();
476 let mut found = false;
477 for line in existing.lines() {
478 let trimmed = line.trim();
479 if !trimmed.starts_with('#')
480 && !trimmed.is_empty()
481 && trimmed
482 .split_once('=')
483 .is_some_and(|(k, _)| k.trim() == key)
484 {
485 lines.push(format!("{}={}", key, new_value));
486 found = true;
487 } else {
488 lines.push(line.to_string());
489 }
490 }
491 if !found {
492 lines.push(format!("{}={}", key, new_value));
493 }
494 let content = lines.join("\n") + "\n";
495 assert!(content.contains("askpass=vault:secret/ssh"));
496 assert!(!content.contains("askpass=old"));
497 assert!(content.contains("sort_mode=alpha"));
498 assert!(found);
499 }
500
501 #[test]
502 fn load_group_by_new_key_none() {
503 let content = "group_by=none\n";
504 let val = parse_value(content, "group_by").unwrap_or_default();
505 assert_eq!(
506 crate::app::GroupBy::from_key(&val),
507 crate::app::GroupBy::None
508 );
509 }
510
511 #[test]
512 fn load_group_by_new_key_provider() {
513 let content = "group_by=provider\n";
514 let val = parse_value(content, "group_by").unwrap_or_default();
515 assert_eq!(
516 crate::app::GroupBy::from_key(&val),
517 crate::app::GroupBy::Provider
518 );
519 }
520
521 #[test]
522 fn load_group_by_new_key_tag() {
523 let content = "group_by=tag:production\n";
524 let val = parse_value(content, "group_by").unwrap_or_default();
525 assert_eq!(
526 crate::app::GroupBy::from_key(&val),
527 crate::app::GroupBy::Tag("production".to_string())
528 );
529 }
530
531 #[test]
532 fn load_group_by_backward_compat_true() {
533 let content = "group_by_provider=true\n";
534 let new_val = parse_value(content, "group_by");
535 let old_val = parse_value(content, "group_by_provider");
536 let result = if let Some(v) = new_val {
537 crate::app::GroupBy::from_key(&v)
538 } else if let Some(v) = old_val {
539 if v == "true" {
540 crate::app::GroupBy::Provider
541 } else {
542 crate::app::GroupBy::None
543 }
544 } else {
545 crate::app::GroupBy::None
546 };
547 assert_eq!(result, crate::app::GroupBy::Provider);
548 }
549
550 #[test]
551 fn load_group_by_backward_compat_false() {
552 let content = "group_by_provider=false\n";
553 let new_val = parse_value(content, "group_by");
554 let old_val = parse_value(content, "group_by_provider");
555 let result = if let Some(v) = new_val {
556 crate::app::GroupBy::from_key(&v)
557 } else if let Some(v) = old_val {
558 if v == "true" {
559 crate::app::GroupBy::Provider
560 } else {
561 crate::app::GroupBy::None
562 }
563 } else {
564 crate::app::GroupBy::None
565 };
566 assert_eq!(result, crate::app::GroupBy::None);
567 }
568
569 #[test]
570 fn load_group_by_new_key_overrides_old() {
571 let content = "group_by_provider=true\ngroup_by=tag:staging\n";
572 let new_val = parse_value(content, "group_by");
573 let old_val = parse_value(content, "group_by_provider");
574 let result = if let Some(v) = new_val {
575 crate::app::GroupBy::from_key(&v)
576 } else if let Some(v) = old_val {
577 if v == "true" {
578 crate::app::GroupBy::Provider
579 } else {
580 crate::app::GroupBy::None
581 }
582 } else {
583 crate::app::GroupBy::None
584 };
585 assert_eq!(result, crate::app::GroupBy::Tag("staging".to_string()));
586 }
587
588 #[test]
589 fn load_group_by_missing_defaults_to_provider() {
590 let content = "sort_mode=alpha\n";
591 let new_val = parse_value(content, "group_by");
592 let old_val = parse_value(content, "group_by_provider");
593 let result = if let Some(v) = new_val {
594 crate::app::GroupBy::from_key(&v)
595 } else if let Some(v) = old_val {
596 if v == "true" {
597 crate::app::GroupBy::Provider
598 } else {
599 crate::app::GroupBy::None
600 }
601 } else {
602 crate::app::GroupBy::Provider
603 };
604 assert_eq!(result, crate::app::GroupBy::Provider);
605 }
606
607 #[test]
608 fn save_group_by_format() {
609 let key = "group_by";
610 let value = crate::app::GroupBy::Tag("production".to_string()).to_key();
611 let line = format!("{}={}", key, value);
612 assert_eq!(line, "group_by=tag:production");
613 }
614
615 #[test]
616 fn save_value_appends_new_key() {
617 let existing = "sort_mode=alpha\n";
618 let key = "askpass";
619 let new_value = "keychain";
620
621 let mut lines: Vec<String> = Vec::new();
622 let mut found = false;
623 for line in existing.lines() {
624 let trimmed = line.trim();
625 if !trimmed.starts_with('#')
626 && !trimmed.is_empty()
627 && trimmed
628 .split_once('=')
629 .is_some_and(|(k, _)| k.trim() == key)
630 {
631 lines.push(format!("{}={}", key, new_value));
632 found = true;
633 } else {
634 lines.push(line.to_string());
635 }
636 }
637 if !found {
638 lines.push(format!("{}={}", key, new_value));
639 }
640 let content = lines.join("\n") + "\n";
641 assert!(content.contains("askpass=keychain"));
642 assert!(content.contains("sort_mode=alpha"));
643 assert!(!found); }
645
646 fn with_temp_prefs<F: FnOnce(&crate::runtime::env::Paths)>(f: F) {
652 super::tests_helpers::with_temp_prefs(f);
653 }
654
655 #[test]
656 fn save_and_load_group_by_roundtrip_tag() {
657 with_temp_prefs(|paths| {
658 let mode = crate::app::GroupBy::Tag("production".to_string());
659 save_group_by(Some(paths), &mode).unwrap();
660 let loaded = load_group_by(Some(paths));
661 assert_eq!(loaded, crate::app::GroupBy::Tag("production".to_string()));
662 });
663 }
664
665 #[test]
666 fn ssh_command_roundtrip_and_empty_means_unset() {
667 with_temp_prefs(|paths| {
668 assert_eq!(load_ssh_command(Some(paths)), None);
669 save_ssh_command(Some(paths), "kitten ssh").unwrap();
670 assert_eq!(
671 load_ssh_command(Some(paths)),
672 Some("kitten ssh".to_string())
673 );
674 save_ssh_command(Some(paths), "").unwrap();
676 assert_eq!(load_ssh_command(Some(paths)), None);
677 std::fs::write(paths.preferences(), "ssh_command= \ntheme=purple\n").unwrap();
678 assert_eq!(load_ssh_command(Some(paths)), None);
679 });
680 }
681
682 #[test]
683 fn save_and_load_group_by_roundtrip_provider() {
684 with_temp_prefs(|paths| {
685 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
686 let loaded = load_group_by(Some(paths));
687 assert_eq!(loaded, crate::app::GroupBy::Provider);
688 });
689 }
690
691 #[test]
692 fn save_and_load_group_by_roundtrip_none() {
693 with_temp_prefs(|paths| {
694 save_group_by(Some(paths), &crate::app::GroupBy::None).unwrap();
695 let loaded = load_group_by(Some(paths));
696 assert_eq!(loaded, crate::app::GroupBy::None);
697 });
698 }
699
700 #[test]
701 fn save_group_by_removes_legacy_key() {
702 with_temp_prefs(|paths| {
703 let path = paths.preferences();
704 std::fs::write(&path, "group_by_provider=true\nsort_mode=alpha\n").unwrap();
705 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
706 let content = std::fs::read_to_string(&path).unwrap();
707 assert!(
708 content.contains("group_by=provider"),
709 "new key should exist"
710 );
711 assert!(
712 !content.contains("group_by_provider"),
713 "legacy key should be removed"
714 );
715 assert!(content.contains("sort_mode=alpha"), "other keys preserved");
716 });
717 }
718
719 #[test]
720 fn load_group_by_backward_compat_real_file() {
721 with_temp_prefs(|paths| {
722 std::fs::write(paths.preferences(), "group_by_provider=true\n").unwrap();
723 let loaded = load_group_by(Some(paths));
724 assert_eq!(loaded, crate::app::GroupBy::Provider);
725 });
726 }
727
728 #[test]
729 fn load_group_by_empty_file_defaults_to_provider() {
730 with_temp_prefs(|paths| {
731 std::fs::write(paths.preferences(), "").unwrap();
732 let loaded = load_group_by(Some(paths));
733 assert_eq!(loaded, crate::app::GroupBy::Provider);
734 });
735 }
736
737 #[test]
738 fn load_group_by_missing_file_defaults_to_provider() {
739 with_temp_prefs(|paths| {
740 assert!(!paths.preferences().exists());
743 let loaded = load_group_by(Some(paths));
744 assert_eq!(loaded, crate::app::GroupBy::Provider);
745 });
746 }
747
748 #[test]
749 fn save_group_by_tag_with_special_chars_roundtrip() {
750 with_temp_prefs(|paths| {
751 let mode = crate::app::GroupBy::Tag("us-east-1".to_string());
752 save_group_by(Some(paths), &mode).unwrap();
753 let loaded = load_group_by(Some(paths));
754 assert_eq!(loaded, crate::app::GroupBy::Tag("us-east-1".to_string()));
755 });
756 }
757
758 #[test]
759 fn save_group_by_preserves_other_prefs() {
760 with_temp_prefs(|paths| {
761 let path = paths.preferences();
762 std::fs::write(&path, "sort_mode=alpha\nview_mode=detailed\n").unwrap();
763 save_group_by(
764 Some(paths),
765 &crate::app::GroupBy::Tag("staging".to_string()),
766 )
767 .unwrap();
768 let content = std::fs::read_to_string(&path).unwrap();
769 assert!(content.contains("sort_mode=alpha"), "sort_mode preserved");
770 assert!(
771 content.contains("view_mode=detailed"),
772 "view_mode preserved"
773 );
774 assert!(content.contains("group_by=tag:staging"), "group_by written");
775 });
776 }
777
778 #[test]
779 fn remove_value_noop_when_key_not_present() {
780 let content = "sort_mode=alpha\nview_mode=compact\n";
781 let lines: Vec<&str> = content.lines().collect();
782 let has_key = lines.iter().any(|line| {
783 let trimmed = line.trim();
784 !trimmed.starts_with('#')
785 && !trimmed.is_empty()
786 && trimmed
787 .split_once('=')
788 .is_some_and(|(k, _)| k.trim() == "nonexistent")
789 });
790 assert!(!has_key);
791 }
792
793 #[test]
794 fn remove_value_preserves_comments_and_empty_lines() {
795 let content = "# comment\n\nsort_mode=alpha\ngroup_by_provider=true\nview_mode=compact\n";
796 let key = "group_by_provider";
797 let lines: Vec<String> = content
798 .lines()
799 .filter(|line| {
800 let trimmed = line.trim();
801 if trimmed.starts_with('#') || trimmed.is_empty() {
802 return true;
803 }
804 trimmed.split_once('=').is_none_or(|(k, _)| k.trim() != key)
805 })
806 .map(|l| l.to_string())
807 .collect();
808 let result = lines.join("\n") + "\n";
809 assert!(result.contains("# comment"));
810 assert!(result.contains("sort_mode=alpha"));
811 assert!(result.contains("view_mode=compact"));
812 assert!(!result.contains("group_by_provider"));
813 }
814
815 #[test]
816 fn remove_value_handles_key_as_only_line() {
817 let content = "group_by_provider=true\n";
818 let key = "group_by_provider";
819 let lines: Vec<String> = content
820 .lines()
821 .filter(|line| {
822 let trimmed = line.trim();
823 if trimmed.starts_with('#') || trimmed.is_empty() {
824 return true;
825 }
826 trimmed.split_once('=').is_none_or(|(k, _)| k.trim() != key)
827 })
828 .map(|l| l.to_string())
829 .collect();
830 let result = lines.join("\n") + "\n";
831 assert!(!result.contains("group_by_provider"));
832 }
833
834 #[test]
835 fn remove_value_real_file_io() {
836 with_temp_prefs(|paths| {
837 let path = paths.preferences();
838 std::fs::write(
839 &path,
840 "sort_mode=alpha\ngroup_by_provider=true\nview_mode=compact\n",
841 )
842 .unwrap();
843 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
845 let content = std::fs::read_to_string(&path).unwrap();
846 assert!(!content.contains("group_by_provider"));
847 assert!(content.contains("sort_mode=alpha"));
848 assert!(content.contains("view_mode=compact"));
849 });
850 }
851
852 #[test]
853 fn remove_value_noop_real_file_io() {
854 with_temp_prefs(|paths| {
855 let path = paths.preferences();
856 std::fs::write(&path, "sort_mode=alpha\n").unwrap();
857 let before = std::fs::read_to_string(&path).unwrap();
858 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
861 let after = std::fs::read_to_string(&path).unwrap();
862 assert!(after.contains("sort_mode=alpha"));
865 assert!(!before.contains("group_by_provider"));
866 assert!(!after.contains("group_by_provider"));
867 });
868 }
869
870 #[test]
873 fn load_view_mode_defaults_to_detailed() {
874 with_temp_prefs(|paths| {
875 let mode = load_view_mode(Some(paths));
878 assert_eq!(mode, ViewMode::Detailed);
879 });
880 }
881
882 #[test]
883 fn load_view_mode_explicit_compact() {
884 with_temp_prefs(|paths| {
885 std::fs::write(paths.preferences(), "view_mode=compact\n").unwrap();
886 let mode = load_view_mode(Some(paths));
887 assert_eq!(mode, ViewMode::Compact);
888 });
889 }
890
891 #[test]
894 fn load_containers_sort_mode_defaults_to_alpha_host() {
895 with_temp_prefs(|paths| {
896 assert_eq!(
897 load_containers_sort_mode(Some(paths)),
898 ContainersSortMode::AlphaHost
899 );
900 });
901 }
902
903 #[test]
904 fn save_load_containers_sort_mode_round_trip() {
905 with_temp_prefs(|paths| {
906 save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaContainer).unwrap();
907 assert_eq!(
908 load_containers_sort_mode(Some(paths)),
909 ContainersSortMode::AlphaContainer
910 );
911 save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaHost).unwrap();
912 assert_eq!(
913 load_containers_sort_mode(Some(paths)),
914 ContainersSortMode::AlphaHost
915 );
916 });
917 }
918
919 #[test]
920 fn load_containers_sort_mode_unknown_value_falls_back_to_default() {
921 with_temp_prefs(|paths| {
922 std::fs::write(paths.preferences(), "containers_sort_mode=garbage\n").unwrap();
923 assert_eq!(
924 load_containers_sort_mode(Some(paths)),
925 ContainersSortMode::AlphaHost
926 );
927 });
928 }
929
930 #[test]
931 fn containers_sort_mode_does_not_clobber_host_sort_mode() {
932 with_temp_prefs(|paths| {
933 save_sort_mode(Some(paths), SortMode::AlphaAlias).unwrap();
934 save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaContainer).unwrap();
935 let content = std::fs::read_to_string(paths.preferences()).unwrap();
936 assert!(content.contains("sort_mode=alpha_alias"));
937 assert!(content.contains("containers_sort_mode=alpha_container"));
938 assert_eq!(load_sort_mode(Some(paths)), SortMode::AlphaAlias);
939 assert_eq!(
940 load_containers_sort_mode(Some(paths)),
941 ContainersSortMode::AlphaContainer
942 );
943 });
944 }
945
946 #[test]
949 fn load_containers_view_mode_defaults_to_detailed() {
950 with_temp_prefs(|paths| {
951 assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Detailed);
952 });
953 }
954
955 #[test]
956 fn save_load_containers_view_mode_round_trip() {
957 with_temp_prefs(|paths| {
958 save_containers_view_mode(Some(paths), ViewMode::Compact).unwrap();
959 assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Compact);
960 save_containers_view_mode(Some(paths), ViewMode::Detailed).unwrap();
961 assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Detailed);
962 });
963 }
964
965 #[test]
966 fn save_containers_collapsed_hosts_writes_sorted_csv() {
967 with_temp_prefs(|paths| {
968 let mut set = std::collections::HashSet::new();
969 set.insert("zeus".to_string());
970 set.insert("apollo".to_string());
971 set.insert("hera".to_string());
972 save_containers_collapsed_hosts(Some(paths), &set).unwrap();
973 let content = std::fs::read_to_string(paths.preferences()).unwrap();
974 assert!(content.contains("containers_collapsed_hosts=apollo,hera,zeus"));
976 });
977 }
978
979 #[test]
980 fn save_containers_collapsed_hosts_empty_clears_key() {
981 with_temp_prefs(|paths| {
982 let path = paths.preferences();
983 std::fs::write(&path, "containers_collapsed_hosts=alpha\n").unwrap();
984 save_containers_collapsed_hosts(Some(paths), &std::collections::HashSet::new())
985 .unwrap();
986 let content = std::fs::read_to_string(&path).unwrap();
987 assert!(
988 !content.contains("containers_collapsed_hosts"),
989 "empty set must remove the key entirely"
990 );
991 });
992 }
993
994 #[test]
995 fn load_containers_collapsed_hosts_round_trip() {
996 with_temp_prefs(|paths| {
997 let mut set = std::collections::HashSet::new();
998 set.insert("alpha".to_string());
999 set.insert("bravo".to_string());
1000 save_containers_collapsed_hosts(Some(paths), &set).unwrap();
1001 let loaded = load_containers_collapsed_hosts(Some(paths));
1002 assert_eq!(loaded, set);
1003 });
1004 }
1005
1006 #[test]
1009 fn load_slow_threshold_default() {
1010 let content = "sort_mode=alpha\n";
1011 let val = parse_value(content, "slow_threshold_ms");
1012 let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
1013 assert_eq!(threshold, 200);
1014 }
1015
1016 #[test]
1017 fn load_slow_threshold_custom() {
1018 let content = "slow_threshold_ms=500\n";
1019 let val = parse_value(content, "slow_threshold_ms");
1020 let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
1021 assert_eq!(threshold, 500);
1022 }
1023
1024 #[test]
1025 fn load_auto_ping_default_true() {
1026 let content = "sort_mode=alpha\n";
1027 let val = parse_value(content, "auto_ping");
1028 let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1029 assert!(auto_ping);
1030 }
1031
1032 #[test]
1033 fn load_auto_ping_explicit_true() {
1034 let content = "auto_ping=true\n";
1035 let val = parse_value(content, "auto_ping");
1036 let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1037 assert!(auto_ping);
1038 }
1039
1040 #[test]
1041 fn save_and_load_slow_threshold_roundtrip() {
1042 with_temp_prefs(|paths| {
1043 save_slow_threshold(Some(paths), 500).unwrap();
1044 let loaded = load_slow_threshold(Some(paths));
1045 assert_eq!(loaded, 500);
1046 });
1047 }
1048
1049 #[test]
1050 fn auto_ping_roundtrip_true() {
1051 let content = "auto_ping=true\n";
1054 let val = parse_value(content, "auto_ping");
1055 assert_eq!(val.as_deref(), Some("true"));
1056 assert!(val.map(|v| v != "false").unwrap_or(true));
1058 }
1059
1060 #[test]
1061 fn auto_ping_roundtrip_false() {
1062 let content = "auto_ping=false\n";
1063 let val = parse_value(content, "auto_ping");
1064 assert_eq!(val.as_deref(), Some("false"));
1065 assert!(!val.map(|v| v != "false").unwrap_or(true));
1067 }
1068
1069 #[test]
1070 fn load_slow_threshold_invalid_defaults() {
1071 let content = "slow_threshold_ms=abc\n";
1072 let val = parse_value(content, "slow_threshold_ms");
1073 let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
1074 assert_eq!(threshold, 200);
1075 }
1076
1077 #[test]
1078 fn save_and_load_theme_roundtrip() {
1079 with_temp_prefs(|paths| {
1080 save_theme(Some(paths), "catppuccin-mocha").unwrap();
1081 let loaded = load_theme(Some(paths));
1082 assert_eq!(loaded, Some("catppuccin-mocha".to_string()));
1083 });
1084 }
1085
1086 #[test]
1087 fn load_theme_missing_returns_none() {
1088 with_temp_prefs(|paths| {
1089 std::fs::write(paths.preferences(), "sort_mode=alpha\n").unwrap();
1090 let loaded = load_theme(Some(paths));
1091 assert_eq!(loaded, None);
1092 });
1093 }
1094
1095 #[test]
1096 fn load_auto_ping_explicit_false() {
1097 let content = "auto_ping=false\n";
1098 let val = parse_value(content, "auto_ping");
1099 let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1100 assert!(!auto_ping);
1101 }
1102
1103 #[test]
1104 fn last_seen_version_round_trip() {
1105 with_temp_prefs(|paths| {
1106 save_last_seen_version(Some(paths), "2.41.0").unwrap();
1107 let loaded = load_last_seen_version(Some(paths)).unwrap();
1108 assert_eq!(loaded.as_deref(), Some("2.41.0"));
1109 });
1110 }
1111
1112 #[test]
1113 fn last_seen_version_returns_none_when_unset() {
1114 with_temp_prefs(|paths| {
1115 let loaded = load_last_seen_version(Some(paths)).unwrap();
1116 assert_eq!(loaded, None);
1117 });
1118 }
1119
1120 #[test]
1121 fn recovered_lock_survives_poison() {
1122 let lock: std::sync::Arc<std::sync::Mutex<Option<PathBuf>>> =
1123 std::sync::Arc::new(std::sync::Mutex::new(None));
1124 let poisoner = lock.clone();
1125 let joined = std::thread::spawn(move || {
1126 let _guard = poisoner.lock().unwrap();
1127 panic!("intentional poison for test");
1128 })
1129 .join();
1130 assert!(joined.is_err(), "poisoning thread must have panicked");
1131 assert!(lock.is_poisoned(), "mutex must be poisoned after panic");
1132
1133 let recovered = lock.lock().unwrap_or_else(|e| e.into_inner());
1135 assert!(
1136 recovered.is_none(),
1137 "recovered lock must expose inner value"
1138 );
1139 }
1140}