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 if k.trim() == key {
44 return Some(v.trim().to_string());
45 }
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
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn parse_value(content: &str, key: &str) -> Option<String> {
388 for line in content.lines() {
389 let line = line.trim();
390 if line.starts_with('#') || line.is_empty() {
391 continue;
392 }
393 if let Some((k, v)) = line.split_once('=') {
394 if k.trim() == key {
395 return Some(v.trim().to_string());
396 }
397 }
398 }
399 None
400 }
401
402 #[test]
403 fn load_askpass_returns_value() {
404 let content = "askpass=keychain\n";
405 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
406 assert_eq!(val, Some("keychain".to_string()));
407 }
408
409 #[test]
410 fn load_askpass_returns_none_for_empty() {
411 let content = "askpass=\n";
412 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
413 assert_eq!(val, None);
414 }
415
416 #[test]
417 fn load_askpass_returns_none_when_missing() {
418 let content = "sort_mode=alpha\n";
419 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
420 assert_eq!(val, None);
421 }
422
423 #[test]
424 fn load_askpass_preserves_vault_uri() {
425 let content = "askpass=vault:secret/ssh#password\n";
426 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
427 assert_eq!(val, Some("vault:secret/ssh#password".to_string()));
428 }
429
430 #[test]
431 fn load_askpass_preserves_op_uri() {
432 let content = "askpass=op://Vault/SSH/password\n";
433 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
434 assert_eq!(val, Some("op://Vault/SSH/password".to_string()));
435 }
436
437 #[test]
438 fn load_askpass_among_other_prefs() {
439 let content = "sort_mode=alpha\ngroup_by_provider=true\naskpass=bw:my-item\n";
440 let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
441 assert_eq!(val, Some("bw:my-item".to_string()));
442 }
443
444 #[test]
445 fn save_value_builds_correct_line() {
446 let key = "askpass";
448 let value = "keychain";
449 let line = format!("{}={}", key, value);
450 assert_eq!(line, "askpass=keychain");
451 }
452
453 #[test]
454 fn save_value_replaces_existing() {
455 let existing = "sort_mode=alpha\naskpass=old\n";
457 let key = "askpass";
458 let new_value = "vault:secret/ssh";
459
460 let mut lines: Vec<String> = Vec::new();
461 let mut found = false;
462 for line in existing.lines() {
463 let trimmed = line.trim();
464 if !trimmed.starts_with('#')
465 && !trimmed.is_empty()
466 && trimmed
467 .split_once('=')
468 .is_some_and(|(k, _)| k.trim() == key)
469 {
470 lines.push(format!("{}={}", key, new_value));
471 found = true;
472 } else {
473 lines.push(line.to_string());
474 }
475 }
476 if !found {
477 lines.push(format!("{}={}", key, new_value));
478 }
479 let content = lines.join("\n") + "\n";
480 assert!(content.contains("askpass=vault:secret/ssh"));
481 assert!(!content.contains("askpass=old"));
482 assert!(content.contains("sort_mode=alpha"));
483 assert!(found);
484 }
485
486 #[test]
487 fn load_group_by_new_key_none() {
488 let content = "group_by=none\n";
489 let val = parse_value(content, "group_by").unwrap_or_default();
490 assert_eq!(
491 crate::app::GroupBy::from_key(&val),
492 crate::app::GroupBy::None
493 );
494 }
495
496 #[test]
497 fn load_group_by_new_key_provider() {
498 let content = "group_by=provider\n";
499 let val = parse_value(content, "group_by").unwrap_or_default();
500 assert_eq!(
501 crate::app::GroupBy::from_key(&val),
502 crate::app::GroupBy::Provider
503 );
504 }
505
506 #[test]
507 fn load_group_by_new_key_tag() {
508 let content = "group_by=tag:production\n";
509 let val = parse_value(content, "group_by").unwrap_or_default();
510 assert_eq!(
511 crate::app::GroupBy::from_key(&val),
512 crate::app::GroupBy::Tag("production".to_string())
513 );
514 }
515
516 #[test]
517 fn load_group_by_backward_compat_true() {
518 let content = "group_by_provider=true\n";
519 let new_val = parse_value(content, "group_by");
520 let old_val = parse_value(content, "group_by_provider");
521 let result = if let Some(v) = new_val {
522 crate::app::GroupBy::from_key(&v)
523 } else if let Some(v) = old_val {
524 if v == "true" {
525 crate::app::GroupBy::Provider
526 } else {
527 crate::app::GroupBy::None
528 }
529 } else {
530 crate::app::GroupBy::None
531 };
532 assert_eq!(result, crate::app::GroupBy::Provider);
533 }
534
535 #[test]
536 fn load_group_by_backward_compat_false() {
537 let content = "group_by_provider=false\n";
538 let new_val = parse_value(content, "group_by");
539 let old_val = parse_value(content, "group_by_provider");
540 let result = if let Some(v) = new_val {
541 crate::app::GroupBy::from_key(&v)
542 } else if let Some(v) = old_val {
543 if v == "true" {
544 crate::app::GroupBy::Provider
545 } else {
546 crate::app::GroupBy::None
547 }
548 } else {
549 crate::app::GroupBy::None
550 };
551 assert_eq!(result, crate::app::GroupBy::None);
552 }
553
554 #[test]
555 fn load_group_by_new_key_overrides_old() {
556 let content = "group_by_provider=true\ngroup_by=tag:staging\n";
557 let new_val = parse_value(content, "group_by");
558 let old_val = parse_value(content, "group_by_provider");
559 let result = if let Some(v) = new_val {
560 crate::app::GroupBy::from_key(&v)
561 } else if let Some(v) = old_val {
562 if v == "true" {
563 crate::app::GroupBy::Provider
564 } else {
565 crate::app::GroupBy::None
566 }
567 } else {
568 crate::app::GroupBy::None
569 };
570 assert_eq!(result, crate::app::GroupBy::Tag("staging".to_string()));
571 }
572
573 #[test]
574 fn load_group_by_missing_defaults_to_provider() {
575 let content = "sort_mode=alpha\n";
576 let new_val = parse_value(content, "group_by");
577 let old_val = parse_value(content, "group_by_provider");
578 let result = if let Some(v) = new_val {
579 crate::app::GroupBy::from_key(&v)
580 } else if let Some(v) = old_val {
581 if v == "true" {
582 crate::app::GroupBy::Provider
583 } else {
584 crate::app::GroupBy::None
585 }
586 } else {
587 crate::app::GroupBy::Provider
588 };
589 assert_eq!(result, crate::app::GroupBy::Provider);
590 }
591
592 #[test]
593 fn save_group_by_format() {
594 let key = "group_by";
595 let value = crate::app::GroupBy::Tag("production".to_string()).to_key();
596 let line = format!("{}={}", key, value);
597 assert_eq!(line, "group_by=tag:production");
598 }
599
600 #[test]
601 fn save_value_appends_new_key() {
602 let existing = "sort_mode=alpha\n";
603 let key = "askpass";
604 let new_value = "keychain";
605
606 let mut lines: Vec<String> = Vec::new();
607 let mut found = false;
608 for line in existing.lines() {
609 let trimmed = line.trim();
610 if !trimmed.starts_with('#')
611 && !trimmed.is_empty()
612 && trimmed
613 .split_once('=')
614 .is_some_and(|(k, _)| k.trim() == key)
615 {
616 lines.push(format!("{}={}", key, new_value));
617 found = true;
618 } else {
619 lines.push(line.to_string());
620 }
621 }
622 if !found {
623 lines.push(format!("{}={}", key, new_value));
624 }
625 let content = lines.join("\n") + "\n";
626 assert!(content.contains("askpass=keychain"));
627 assert!(content.contains("sort_mode=alpha"));
628 assert!(!found); }
630
631 fn with_temp_prefs<F: FnOnce(&crate::runtime::env::Paths)>(f: F) {
637 super::tests_helpers::with_temp_prefs(f);
638 }
639
640 #[test]
641 fn save_and_load_group_by_roundtrip_tag() {
642 with_temp_prefs(|paths| {
643 let mode = crate::app::GroupBy::Tag("production".to_string());
644 save_group_by(Some(paths), &mode).unwrap();
645 let loaded = load_group_by(Some(paths));
646 assert_eq!(loaded, crate::app::GroupBy::Tag("production".to_string()));
647 });
648 }
649
650 #[test]
651 fn save_and_load_group_by_roundtrip_provider() {
652 with_temp_prefs(|paths| {
653 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
654 let loaded = load_group_by(Some(paths));
655 assert_eq!(loaded, crate::app::GroupBy::Provider);
656 });
657 }
658
659 #[test]
660 fn save_and_load_group_by_roundtrip_none() {
661 with_temp_prefs(|paths| {
662 save_group_by(Some(paths), &crate::app::GroupBy::None).unwrap();
663 let loaded = load_group_by(Some(paths));
664 assert_eq!(loaded, crate::app::GroupBy::None);
665 });
666 }
667
668 #[test]
669 fn save_group_by_removes_legacy_key() {
670 with_temp_prefs(|paths| {
671 let path = paths.preferences();
672 std::fs::write(&path, "group_by_provider=true\nsort_mode=alpha\n").unwrap();
673 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
674 let content = std::fs::read_to_string(&path).unwrap();
675 assert!(
676 content.contains("group_by=provider"),
677 "new key should exist"
678 );
679 assert!(
680 !content.contains("group_by_provider"),
681 "legacy key should be removed"
682 );
683 assert!(content.contains("sort_mode=alpha"), "other keys preserved");
684 });
685 }
686
687 #[test]
688 fn load_group_by_backward_compat_real_file() {
689 with_temp_prefs(|paths| {
690 std::fs::write(paths.preferences(), "group_by_provider=true\n").unwrap();
691 let loaded = load_group_by(Some(paths));
692 assert_eq!(loaded, crate::app::GroupBy::Provider);
693 });
694 }
695
696 #[test]
697 fn load_group_by_empty_file_defaults_to_provider() {
698 with_temp_prefs(|paths| {
699 std::fs::write(paths.preferences(), "").unwrap();
700 let loaded = load_group_by(Some(paths));
701 assert_eq!(loaded, crate::app::GroupBy::Provider);
702 });
703 }
704
705 #[test]
706 fn load_group_by_missing_file_defaults_to_provider() {
707 with_temp_prefs(|paths| {
708 assert!(!paths.preferences().exists());
711 let loaded = load_group_by(Some(paths));
712 assert_eq!(loaded, crate::app::GroupBy::Provider);
713 });
714 }
715
716 #[test]
717 fn save_group_by_tag_with_special_chars_roundtrip() {
718 with_temp_prefs(|paths| {
719 let mode = crate::app::GroupBy::Tag("us-east-1".to_string());
720 save_group_by(Some(paths), &mode).unwrap();
721 let loaded = load_group_by(Some(paths));
722 assert_eq!(loaded, crate::app::GroupBy::Tag("us-east-1".to_string()));
723 });
724 }
725
726 #[test]
727 fn save_group_by_preserves_other_prefs() {
728 with_temp_prefs(|paths| {
729 let path = paths.preferences();
730 std::fs::write(&path, "sort_mode=alpha\nview_mode=detailed\n").unwrap();
731 save_group_by(
732 Some(paths),
733 &crate::app::GroupBy::Tag("staging".to_string()),
734 )
735 .unwrap();
736 let content = std::fs::read_to_string(&path).unwrap();
737 assert!(content.contains("sort_mode=alpha"), "sort_mode preserved");
738 assert!(
739 content.contains("view_mode=detailed"),
740 "view_mode preserved"
741 );
742 assert!(content.contains("group_by=tag:staging"), "group_by written");
743 });
744 }
745
746 #[test]
747 fn remove_value_noop_when_key_not_present() {
748 let content = "sort_mode=alpha\nview_mode=compact\n";
749 let lines: Vec<&str> = content.lines().collect();
750 let has_key = lines.iter().any(|line| {
751 let trimmed = line.trim();
752 !trimmed.starts_with('#')
753 && !trimmed.is_empty()
754 && trimmed
755 .split_once('=')
756 .is_some_and(|(k, _)| k.trim() == "nonexistent")
757 });
758 assert!(!has_key);
759 }
760
761 #[test]
762 fn remove_value_preserves_comments_and_empty_lines() {
763 let content = "# comment\n\nsort_mode=alpha\ngroup_by_provider=true\nview_mode=compact\n";
764 let key = "group_by_provider";
765 let lines: Vec<String> = content
766 .lines()
767 .filter(|line| {
768 let trimmed = line.trim();
769 if trimmed.starts_with('#') || trimmed.is_empty() {
770 return true;
771 }
772 trimmed.split_once('=').is_none_or(|(k, _)| k.trim() != key)
773 })
774 .map(|l| l.to_string())
775 .collect();
776 let result = lines.join("\n") + "\n";
777 assert!(result.contains("# comment"));
778 assert!(result.contains("sort_mode=alpha"));
779 assert!(result.contains("view_mode=compact"));
780 assert!(!result.contains("group_by_provider"));
781 }
782
783 #[test]
784 fn remove_value_handles_key_as_only_line() {
785 let content = "group_by_provider=true\n";
786 let key = "group_by_provider";
787 let lines: Vec<String> = content
788 .lines()
789 .filter(|line| {
790 let trimmed = line.trim();
791 if trimmed.starts_with('#') || trimmed.is_empty() {
792 return true;
793 }
794 trimmed.split_once('=').is_none_or(|(k, _)| k.trim() != key)
795 })
796 .map(|l| l.to_string())
797 .collect();
798 let result = lines.join("\n") + "\n";
799 assert!(!result.contains("group_by_provider"));
800 }
801
802 #[test]
803 fn remove_value_real_file_io() {
804 with_temp_prefs(|paths| {
805 let path = paths.preferences();
806 std::fs::write(
807 &path,
808 "sort_mode=alpha\ngroup_by_provider=true\nview_mode=compact\n",
809 )
810 .unwrap();
811 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
813 let content = std::fs::read_to_string(&path).unwrap();
814 assert!(!content.contains("group_by_provider"));
815 assert!(content.contains("sort_mode=alpha"));
816 assert!(content.contains("view_mode=compact"));
817 });
818 }
819
820 #[test]
821 fn remove_value_noop_real_file_io() {
822 with_temp_prefs(|paths| {
823 let path = paths.preferences();
824 std::fs::write(&path, "sort_mode=alpha\n").unwrap();
825 let before = std::fs::read_to_string(&path).unwrap();
826 save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
829 let after = std::fs::read_to_string(&path).unwrap();
830 assert!(after.contains("sort_mode=alpha"));
833 assert!(!before.contains("group_by_provider"));
834 assert!(!after.contains("group_by_provider"));
835 });
836 }
837
838 #[test]
841 fn load_view_mode_defaults_to_detailed() {
842 with_temp_prefs(|paths| {
843 let mode = load_view_mode(Some(paths));
846 assert_eq!(mode, ViewMode::Detailed);
847 });
848 }
849
850 #[test]
851 fn load_view_mode_explicit_compact() {
852 with_temp_prefs(|paths| {
853 std::fs::write(paths.preferences(), "view_mode=compact\n").unwrap();
854 let mode = load_view_mode(Some(paths));
855 assert_eq!(mode, ViewMode::Compact);
856 });
857 }
858
859 #[test]
862 fn load_containers_sort_mode_defaults_to_alpha_host() {
863 with_temp_prefs(|paths| {
864 assert_eq!(
865 load_containers_sort_mode(Some(paths)),
866 ContainersSortMode::AlphaHost
867 );
868 });
869 }
870
871 #[test]
872 fn save_load_containers_sort_mode_round_trip() {
873 with_temp_prefs(|paths| {
874 save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaContainer).unwrap();
875 assert_eq!(
876 load_containers_sort_mode(Some(paths)),
877 ContainersSortMode::AlphaContainer
878 );
879 save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaHost).unwrap();
880 assert_eq!(
881 load_containers_sort_mode(Some(paths)),
882 ContainersSortMode::AlphaHost
883 );
884 });
885 }
886
887 #[test]
888 fn load_containers_sort_mode_unknown_value_falls_back_to_default() {
889 with_temp_prefs(|paths| {
890 std::fs::write(paths.preferences(), "containers_sort_mode=garbage\n").unwrap();
891 assert_eq!(
892 load_containers_sort_mode(Some(paths)),
893 ContainersSortMode::AlphaHost
894 );
895 });
896 }
897
898 #[test]
899 fn containers_sort_mode_does_not_clobber_host_sort_mode() {
900 with_temp_prefs(|paths| {
901 save_sort_mode(Some(paths), SortMode::AlphaAlias).unwrap();
902 save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaContainer).unwrap();
903 let content = std::fs::read_to_string(paths.preferences()).unwrap();
904 assert!(content.contains("sort_mode=alpha_alias"));
905 assert!(content.contains("containers_sort_mode=alpha_container"));
906 assert_eq!(load_sort_mode(Some(paths)), SortMode::AlphaAlias);
907 assert_eq!(
908 load_containers_sort_mode(Some(paths)),
909 ContainersSortMode::AlphaContainer
910 );
911 });
912 }
913
914 #[test]
917 fn load_containers_view_mode_defaults_to_detailed() {
918 with_temp_prefs(|paths| {
919 assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Detailed);
920 });
921 }
922
923 #[test]
924 fn save_load_containers_view_mode_round_trip() {
925 with_temp_prefs(|paths| {
926 save_containers_view_mode(Some(paths), ViewMode::Compact).unwrap();
927 assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Compact);
928 save_containers_view_mode(Some(paths), ViewMode::Detailed).unwrap();
929 assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Detailed);
930 });
931 }
932
933 #[test]
934 fn save_containers_collapsed_hosts_writes_sorted_csv() {
935 with_temp_prefs(|paths| {
936 let mut set = std::collections::HashSet::new();
937 set.insert("zeus".to_string());
938 set.insert("apollo".to_string());
939 set.insert("hera".to_string());
940 save_containers_collapsed_hosts(Some(paths), &set).unwrap();
941 let content = std::fs::read_to_string(paths.preferences()).unwrap();
942 assert!(content.contains("containers_collapsed_hosts=apollo,hera,zeus"));
944 });
945 }
946
947 #[test]
948 fn save_containers_collapsed_hosts_empty_clears_key() {
949 with_temp_prefs(|paths| {
950 let path = paths.preferences();
951 std::fs::write(&path, "containers_collapsed_hosts=alpha\n").unwrap();
952 save_containers_collapsed_hosts(Some(paths), &std::collections::HashSet::new())
953 .unwrap();
954 let content = std::fs::read_to_string(&path).unwrap();
955 assert!(
956 !content.contains("containers_collapsed_hosts"),
957 "empty set must remove the key entirely"
958 );
959 });
960 }
961
962 #[test]
963 fn load_containers_collapsed_hosts_round_trip() {
964 with_temp_prefs(|paths| {
965 let mut set = std::collections::HashSet::new();
966 set.insert("alpha".to_string());
967 set.insert("bravo".to_string());
968 save_containers_collapsed_hosts(Some(paths), &set).unwrap();
969 let loaded = load_containers_collapsed_hosts(Some(paths));
970 assert_eq!(loaded, set);
971 });
972 }
973
974 #[test]
977 fn load_slow_threshold_default() {
978 let content = "sort_mode=alpha\n";
979 let val = parse_value(content, "slow_threshold_ms");
980 let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
981 assert_eq!(threshold, 200);
982 }
983
984 #[test]
985 fn load_slow_threshold_custom() {
986 let content = "slow_threshold_ms=500\n";
987 let val = parse_value(content, "slow_threshold_ms");
988 let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
989 assert_eq!(threshold, 500);
990 }
991
992 #[test]
993 fn load_auto_ping_default_true() {
994 let content = "sort_mode=alpha\n";
995 let val = parse_value(content, "auto_ping");
996 let auto_ping = val.map(|v| v != "false").unwrap_or(true);
997 assert!(auto_ping);
998 }
999
1000 #[test]
1001 fn load_auto_ping_explicit_true() {
1002 let content = "auto_ping=true\n";
1003 let val = parse_value(content, "auto_ping");
1004 let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1005 assert!(auto_ping);
1006 }
1007
1008 #[test]
1009 fn save_and_load_slow_threshold_roundtrip() {
1010 with_temp_prefs(|paths| {
1011 save_slow_threshold(Some(paths), 500).unwrap();
1012 let loaded = load_slow_threshold(Some(paths));
1013 assert_eq!(loaded, 500);
1014 });
1015 }
1016
1017 #[test]
1018 fn auto_ping_roundtrip_true() {
1019 let content = "auto_ping=true\n";
1022 let val = parse_value(content, "auto_ping");
1023 assert_eq!(val.as_deref(), Some("true"));
1024 assert!(val.map(|v| v != "false").unwrap_or(true));
1026 }
1027
1028 #[test]
1029 fn auto_ping_roundtrip_false() {
1030 let content = "auto_ping=false\n";
1031 let val = parse_value(content, "auto_ping");
1032 assert_eq!(val.as_deref(), Some("false"));
1033 assert!(!val.map(|v| v != "false").unwrap_or(true));
1035 }
1036
1037 #[test]
1038 fn load_slow_threshold_invalid_defaults() {
1039 let content = "slow_threshold_ms=abc\n";
1040 let val = parse_value(content, "slow_threshold_ms");
1041 let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
1042 assert_eq!(threshold, 200);
1043 }
1044
1045 #[test]
1046 fn save_and_load_theme_roundtrip() {
1047 with_temp_prefs(|paths| {
1048 save_theme(Some(paths), "catppuccin-mocha").unwrap();
1049 let loaded = load_theme(Some(paths));
1050 assert_eq!(loaded, Some("catppuccin-mocha".to_string()));
1051 });
1052 }
1053
1054 #[test]
1055 fn load_theme_missing_returns_none() {
1056 with_temp_prefs(|paths| {
1057 std::fs::write(paths.preferences(), "sort_mode=alpha\n").unwrap();
1058 let loaded = load_theme(Some(paths));
1059 assert_eq!(loaded, None);
1060 });
1061 }
1062
1063 #[test]
1064 fn load_auto_ping_explicit_false() {
1065 let content = "auto_ping=false\n";
1066 let val = parse_value(content, "auto_ping");
1067 let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1068 assert!(!auto_ping);
1069 }
1070
1071 #[test]
1072 fn last_seen_version_round_trip() {
1073 with_temp_prefs(|paths| {
1074 save_last_seen_version(Some(paths), "2.41.0").unwrap();
1075 let loaded = load_last_seen_version(Some(paths)).unwrap();
1076 assert_eq!(loaded.as_deref(), Some("2.41.0"));
1077 });
1078 }
1079
1080 #[test]
1081 fn last_seen_version_returns_none_when_unset() {
1082 with_temp_prefs(|paths| {
1083 let loaded = load_last_seen_version(Some(paths)).unwrap();
1084 assert_eq!(loaded, None);
1085 });
1086 }
1087
1088 #[test]
1089 fn recovered_lock_survives_poison() {
1090 let lock: std::sync::Arc<std::sync::Mutex<Option<PathBuf>>> =
1091 std::sync::Arc::new(std::sync::Mutex::new(None));
1092 let poisoner = lock.clone();
1093 let joined = std::thread::spawn(move || {
1094 let _guard = poisoner.lock().unwrap();
1095 panic!("intentional poison for test");
1096 })
1097 .join();
1098 assert!(joined.is_err(), "poisoning thread must have panicked");
1099 assert!(lock.is_poisoned(), "mutex must be poisoned after panic");
1100
1101 let recovered = lock.lock().unwrap_or_else(|e| e.into_inner());
1103 assert!(
1104 recovered.is_none(),
1105 "recovered lock must expose inner value"
1106 );
1107 }
1108}