Skip to main content

purple_ssh/
preferences.rs

1use std::io;
2use std::path::PathBuf;
3
4use log::debug;
5
6use crate::app::{ContainersSortMode, SortMode, ViewMode};
7use crate::fs_util;
8
9// In test mode the override is thread-local: cargo runs each test on its own
10// thread, so every test gets an isolated preferences path even when multiple
11// suites call `set_path_override` concurrently. This prevents the classic race
12// where a handler test's `App::new()` reset the override while a preferences
13// test was midway through a two-step write (e.g. save_value + remove_value).
14#[cfg(test)]
15thread_local! {
16    static PATH_OVERRIDE: std::cell::RefCell<Option<PathBuf>> =
17        const { std::cell::RefCell::new(None) };
18}
19
20/// Cross-suite test lock for the `demo_flag` global. Aliases the
21/// library's `purple_ssh::demo_flag::GLOBAL_TEST_LOCK` so binary-only
22/// callers (here, plus visual regression) share the same mutex with
23/// library-only callers (key_activity tests). One mutex per process is
24/// the invariant; the binary-vs-library crate split must not produce
25/// two distinct locks.
26#[cfg(test)]
27pub(crate) use crate::demo_flag::GLOBAL_TEST_LOCK as GLOBAL_TEST_IO_LOCK;
28
29/// Override the preferences file path (used in tests to avoid writing to ~/.purple).
30/// Scoped to the calling thread only.
31#[cfg(test)]
32pub fn set_path_override(path: PathBuf) {
33    PATH_OVERRIDE.with(|p| *p.borrow_mut() = Some(path));
34}
35
36/// Clear the path override so `path()` falls back to the real ~/.purple/preferences.
37/// Scoped to the calling thread only.
38#[cfg(test)]
39fn clear_path_override() {
40    PATH_OVERRIDE.with(|p| *p.borrow_mut() = None);
41}
42
43/// Public wrapper for `clear_path_override`, callable from visual regression
44/// tests and other test suites that need to reset the thread-local override.
45#[cfg(test)]
46pub fn clear_path_override_for_tests() {
47    clear_path_override();
48}
49
50fn path() -> Option<PathBuf> {
51    // Tests MUST opt in via `set_path_override`. Falling through to
52    // the real `~/.purple/preferences` would let a forgotten
53    // override read the user's saved settings and, worse, write
54    // back into them on `save_value`. Mirrors the same guard in
55    // `containers::cache_path`.
56    #[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
66/// Load a value for a given key from ~/.purple/preferences.
67fn 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
92/// Save a key=value pair to ~/.purple/preferences. Preserves unknown keys and comments.
93fn save_value(key: &str, value: &str) -> io::Result<()> {
94    let path = match path() {
95        Some(p) => p,
96        None => return Ok(()),
97    };
98    // In production demo mode disk writes are suppressed so the user's
99    // real preferences file stays untouched. Inside tests the path
100    // resolves to a tempfile via the thread-local override, so we let
101    // writes through regardless of the global demo flag (handler
102    // fixtures flip that flag and would otherwise mute every prefs
103    // assertion that runs in parallel with them).
104    #[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
137/// Load sort mode from ~/.purple/preferences. Returns MostRecent if missing or invalid.
138pub fn load_sort_mode() -> SortMode {
139    load_value("sort_mode")
140        .map(|v| SortMode::from_key(&v))
141        .unwrap_or(SortMode::MostRecent)
142}
143
144/// Save sort mode to ~/.purple/preferences.
145pub 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
152/// Load group_by from ~/.purple/preferences. New `group_by` key takes precedence
153/// over the legacy `group_by_provider` key for backward compatibility.
154/// Returns `GroupBy::Provider` if missing (preserving old default behavior).
155pub 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
170/// Remove a key from ~/.purple/preferences. No-op if the key or file does not exist.
171fn remove_value(key: &str) -> io::Result<()> {
172    let path = match path() {
173        Some(p) => p,
174        None => return Ok(()),
175    };
176    // Same demo-vs-test gate as `save_value`: production demo mode
177    // suppresses disk writes; test mode lets a tempfile override
178    // through irrespective of the global flag.
179    #[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    // Early return if key not present — avoids unnecessary rewrite
186    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
213/// Save group_by to ~/.purple/preferences.
214pub 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    // Best-effort cleanup: group_by key takes precedence on load, so
220    // a leftover group_by_provider key is harmless if removal fails.
221    let _ = remove_value("group_by_provider");
222    Ok(())
223}
224
225/// Load view mode from ~/.purple/preferences. Returns Detailed if missing or invalid.
226pub 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
235/// Save view mode to ~/.purple/preferences.
236pub 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
247/// Containers-tab sort order. Separate key from the host-list `sort_mode`
248/// so the two screens persist independently. Default `AlphaHost` matches
249/// `ContainersSortMode::default()`.
250pub 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
267/// Containers-tab detail panel toggle. Separate key so the host-list
268/// preference does not bleed into the containers screen and vice versa.
269/// Default Detailed: when nothing is saved yet the detail panel renders
270/// alongside the list whenever the terminal is wide enough.
271pub 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
295/// Aliases whose containers group is currently folded in the
296/// containers-tab AlphaHost view. Persists as a comma-separated list so
297/// a fresh start restores the user's last fold state. Empty list means
298/// every group is expanded.
299pub 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
331/// Load global askpass default from ~/.purple/preferences.
332pub fn load_askpass_default() -> Option<String> {
333    load_value("askpass").filter(|v| !v.is_empty())
334}
335
336/// Save global askpass default to ~/.purple/preferences.
337pub 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
344/// Load slow threshold from ~/.purple/preferences. Returns 200 if missing or invalid.
345pub 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/// Save slow threshold to ~/.purple/preferences.
352#[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
360/// Load theme name from ~/.purple/preferences. Returns None if missing.
361pub fn load_theme() -> Option<String> {
362    load_value("theme").filter(|v| !v.is_empty())
363}
364
365/// Save theme name to ~/.purple/preferences.
366pub 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
375/// Save the last seen version string to ~/.purple/preferences.
376pub 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
381/// Load the last seen version string from ~/.purple/preferences. Returns None if missing.
382pub fn load_last_seen_version() -> io::Result<Option<String>> {
383    Ok(load_value(LAST_SEEN_VERSION_KEY))
384}
385
386/// Public test helpers for other test modules that need isolated preferences I/O.
387#[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
412/// Load auto_ping preference. Returns true if missing (default: enabled).
413pub fn load_auto_ping() -> bool {
414    load_value("auto_ping")
415        .map(|v| v != "false")
416        .unwrap_or(true)
417}
418
419/// Save auto_ping preference.
420#[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    // We test load_value/save_value logic by replicating the parsing inline,
434    // since the real functions read from ~/.purple/preferences.
435
436    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        // Verify the format that save_value produces
496        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        // Simulate save_value logic
505        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); // Was appended, not replaced
678    }
679
680    // --- Real file I/O tests using set_path_override ---
681    //
682    // PATH_OVERRIDE is a global Mutex<Option<PathBuf>>, so these tests must
683    // not run concurrently. They acquire the crate-level GLOBAL_TEST_IO_LOCK,
684    // which is also held by visual_regression_tests::setup() so the two
685    // suites cannot race on PATH_OVERRIDE or on demo_flag::DEMO_MODE.
686
687    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        // Ensure it does not exist
763        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 calls remove_value("group_by_provider") internally
860            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 calls remove_value("group_by_provider"), which should be a no-op
874            // since the key doesn't exist. We save Provider to trigger the remove path.
875            save_group_by(&crate::app::GroupBy::Provider).unwrap();
876            let after = std::fs::read_to_string(path).unwrap();
877            // The file will have group_by=provider added, but group_by_provider should
878            // not have been written and removed (no-op path exercised)
879            assert!(after.contains("sort_mode=alpha"));
880            assert!(!before.contains("group_by_provider"));
881            assert!(!after.contains("group_by_provider"));
882        });
883    }
884
885    // --- View mode defaults ---
886
887    #[test]
888    fn load_view_mode_defaults_to_detailed() {
889        with_temp_prefs("view_mode_default", |_path| {
890            // No preferences file content written, but file exists (empty)
891            // load_view_mode reads "view_mode" key; missing -> Detailed
892            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    // --- Containers sort mode (separate key from host-list sort_mode) ---
907
908    #[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    // --- Containers view mode (separate key from host-list view_mode) ---
953
954    #[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            // Sorted output keeps the prefs file diff-friendly across runs.
981            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    // --- slow_threshold_ms ---
1011
1012    #[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        // Verify save_auto_ping writes a value that load_auto_ping parses back
1056        // correctly. Uses the parse_value helper to avoid global PATH_OVERRIDE
1057        // races when other tests call App::new() → load_auto_ping() in parallel.
1058        let content = "auto_ping=true\n";
1059        let val = parse_value(content, "auto_ping");
1060        assert_eq!(val.as_deref(), Some("true"));
1061        // Confirm load_auto_ping's parsing logic: anything != "false" → true
1062        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        // Confirm load_auto_ping's parsing logic: "false" → false
1071        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    // Verifies the poison-recovery pattern used by `GLOBAL_TEST_IO_LOCK` callers
1109    // (`with_temp_prefs` and `visual_regression_tests::setup`). Uses a local Mutex
1110    // to avoid poisoning the real lock permanently. The same
1111    // `.lock().unwrap_or_else(|e| e.into_inner())` pattern is used wherever a
1112    // shared Mutex guards cross-test state.
1113    #[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        // The exact pattern used by path() and set_path_override().
1144        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}