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