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;
8use crate::runtime::env::Paths;
9
10/// Cross-suite test lock for the `demo_flag` and theme globals, which remain
11/// process-global for now. Aliased here so existing binary-side callers keep
12/// resolving it under its historical name. The preferences file path itself is
13/// no longer global: it is derived from the injected `Paths`.
14#[cfg(test)]
15pub(crate) use crate::demo_flag::GLOBAL_TEST_LOCK as GLOBAL_TEST_IO_LOCK;
16
17/// The preferences file under the given paths, or `None` when the home
18/// directory is unknown. A `None` here makes every load return the default and
19/// every save a silent no-op, matching the historical behaviour when
20/// `dirs::home_dir()` returned `None`.
21fn prefs_file(paths: Option<&Paths>) -> Option<PathBuf> {
22    paths.map(Paths::preferences)
23}
24
25/// Load a value for a given key from the preferences file.
26fn load_value(paths: Option<&Paths>, key: &str) -> Option<String> {
27    let path = prefs_file(paths)?;
28    let content = match std::fs::read_to_string(&path) {
29        Ok(c) => c,
30        Err(e) => {
31            if e.kind() != std::io::ErrorKind::NotFound {
32                debug!("[config] Failed to read preferences file: {e}");
33            }
34            return None;
35        }
36    };
37    for line in content.lines() {
38        let line = line.trim();
39        if line.starts_with('#') || line.is_empty() {
40            continue;
41        }
42        if let Some((k, v)) = line.split_once('=')
43            && k.trim() == key
44        {
45            return Some(v.trim().to_string());
46        }
47    }
48    None
49}
50
51/// Save a key=value pair to the preferences file. Preserves unknown keys and comments.
52fn 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    // In production demo mode disk writes are suppressed so the user's
58    // real preferences file stays untouched. Inside tests the path comes
59    // from the injected sandbox `Paths`, so we let writes through regardless
60    // of the global demo flag (handler fixtures flip that flag and would
61    // otherwise mute every prefs assertion that runs in parallel with them).
62    #[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
95/// Load sort mode from ~/.purple/preferences. Returns MostRecent if missing or invalid.
96pub 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
102/// Save sort mode to ~/.purple/preferences.
103pub 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
110/// Load group_by from ~/.purple/preferences. New `group_by` key takes precedence
111/// over the legacy `group_by_provider` key for backward compatibility.
112/// Returns `GroupBy::Provider` if missing (preserving old default behavior).
113pub 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
128/// Remove a key from the preferences file. No-op if the key or file does not exist.
129fn 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    // Same demo-vs-test gate as `save_value`: production demo mode
135    // suppresses disk writes; in tests the path is an injected sandbox
136    // so writes go through irrespective of the global flag.
137    #[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    // Early return if key not present — avoids unnecessary rewrite
144    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
171/// Save group_by to ~/.purple/preferences.
172pub 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    // Best-effort cleanup: group_by key takes precedence on load, so
178    // a leftover group_by_provider key is harmless if removal fails.
179    let _ = remove_value(paths, "group_by_provider");
180    Ok(())
181}
182
183/// Load view mode from ~/.purple/preferences. Returns Detailed if missing or invalid.
184pub 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
193/// Save view mode to ~/.purple/preferences.
194pub 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
205/// Containers-tab sort order. Separate key from the host-list `sort_mode`
206/// so the two screens persist independently. Default `AlphaHost` matches
207/// `ContainersSortMode::default()`.
208pub 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
228/// Containers-tab detail panel toggle. Separate key so the host-list
229/// preference does not bleed into the containers screen and vice versa.
230/// Default Detailed: when nothing is saved yet the detail panel renders
231/// alongside the list whenever the terminal is wide enough.
232pub 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
256/// Aliases whose containers group is currently folded in the
257/// containers-tab AlphaHost view. Persists as a comma-separated list so
258/// a fresh start restores the user's last fold state. Empty list means
259/// every group is expanded.
260pub 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
293/// Load global askpass default from ~/.purple/preferences.
294pub fn load_askpass_default(paths: Option<&Paths>) -> Option<String> {
295    load_value(paths, "askpass").filter(|v| !v.is_empty())
296}
297
298/// Save global askpass default to ~/.purple/preferences.
299pub 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
306/// Load slow threshold from ~/.purple/preferences. Returns 200 if missing or invalid.
307pub 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/// Save slow threshold to ~/.purple/preferences.
314#[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
322/// Load theme name from ~/.purple/preferences. Returns None if missing.
323pub fn load_theme(paths: Option<&Paths>) -> Option<String> {
324    load_value(paths, "theme").filter(|v| !v.is_empty())
325}
326
327/// Save theme name to ~/.purple/preferences.
328pub 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
337/// Save the last seen version string to ~/.purple/preferences.
338pub 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
343/// Load the last seen version string from ~/.purple/preferences. Returns None if missing.
344pub fn load_last_seen_version(paths: Option<&Paths>) -> io::Result<Option<String>> {
345    Ok(load_value(paths, LAST_SEEN_VERSION_KEY))
346}
347
348/// Public test helpers for other test modules that need isolated preferences I/O.
349#[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        // Tests that seed the file with std::fs::write need the parent to exist;
355        // atomic_write creates it on its own but a bare std::fs::write does not.
356        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
363/// Load auto_ping preference. Returns true if missing (default: enabled).
364pub 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/// Save auto_ping preference.
371#[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/// Load the interactive ssh command (`ssh_command`, e.g. `kitten ssh`).
381/// Returns None when missing or empty; the caller falls back to plain `ssh`.
382pub fn load_ssh_command(paths: Option<&Paths>) -> Option<String> {
383    load_value(paths, crate::ssh_launcher::PREF_KEY).filter(|v| !v.trim().is_empty())
384}
385
386/// Save the interactive ssh command preference.
387#[allow(dead_code)]
388pub fn save_ssh_command(paths: Option<&Paths>, command: &str) -> io::Result<()> {
389    log::debug!("[purple] saving ssh_command={}", command);
390    save_value(paths, crate::ssh_launcher::PREF_KEY, command).inspect_err(|e| {
391        log::warn!("[config] failed to save ssh_command={}: {}", command, e);
392    })
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    // We test load_value/save_value logic by replicating the parsing inline,
400    // since the real functions read from ~/.purple/preferences.
401
402    fn parse_value(content: &str, key: &str) -> Option<String> {
403        for line in content.lines() {
404            let line = line.trim();
405            if line.starts_with('#') || line.is_empty() {
406                continue;
407            }
408            if let Some((k, v)) = line.split_once('=')
409                && k.trim() == key
410            {
411                return Some(v.trim().to_string());
412            }
413        }
414        None
415    }
416
417    #[test]
418    fn load_askpass_returns_value() {
419        let content = "askpass=keychain\n";
420        let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
421        assert_eq!(val, Some("keychain".to_string()));
422    }
423
424    #[test]
425    fn load_askpass_returns_none_for_empty() {
426        let content = "askpass=\n";
427        let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
428        assert_eq!(val, None);
429    }
430
431    #[test]
432    fn load_askpass_returns_none_when_missing() {
433        let content = "sort_mode=alpha\n";
434        let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
435        assert_eq!(val, None);
436    }
437
438    #[test]
439    fn load_askpass_preserves_vault_uri() {
440        let content = "askpass=vault:secret/ssh#password\n";
441        let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
442        assert_eq!(val, Some("vault:secret/ssh#password".to_string()));
443    }
444
445    #[test]
446    fn load_askpass_preserves_op_uri() {
447        let content = "askpass=op://Vault/SSH/password\n";
448        let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
449        assert_eq!(val, Some("op://Vault/SSH/password".to_string()));
450    }
451
452    #[test]
453    fn load_askpass_among_other_prefs() {
454        let content = "sort_mode=alpha\ngroup_by_provider=true\naskpass=bw:my-item\n";
455        let val = parse_value(content, "askpass").filter(|v| !v.is_empty());
456        assert_eq!(val, Some("bw:my-item".to_string()));
457    }
458
459    #[test]
460    fn save_value_builds_correct_line() {
461        // Verify the format that save_value produces
462        let key = "askpass";
463        let value = "keychain";
464        let line = format!("{}={}", key, value);
465        assert_eq!(line, "askpass=keychain");
466    }
467
468    #[test]
469    fn save_value_replaces_existing() {
470        // Simulate save_value logic
471        let existing = "sort_mode=alpha\naskpass=old\n";
472        let key = "askpass";
473        let new_value = "vault:secret/ssh";
474
475        let mut lines: Vec<String> = Vec::new();
476        let mut found = false;
477        for line in existing.lines() {
478            let trimmed = line.trim();
479            if !trimmed.starts_with('#')
480                && !trimmed.is_empty()
481                && trimmed
482                    .split_once('=')
483                    .is_some_and(|(k, _)| k.trim() == key)
484            {
485                lines.push(format!("{}={}", key, new_value));
486                found = true;
487            } else {
488                lines.push(line.to_string());
489            }
490        }
491        if !found {
492            lines.push(format!("{}={}", key, new_value));
493        }
494        let content = lines.join("\n") + "\n";
495        assert!(content.contains("askpass=vault:secret/ssh"));
496        assert!(!content.contains("askpass=old"));
497        assert!(content.contains("sort_mode=alpha"));
498        assert!(found);
499    }
500
501    #[test]
502    fn load_group_by_new_key_none() {
503        let content = "group_by=none\n";
504        let val = parse_value(content, "group_by").unwrap_or_default();
505        assert_eq!(
506            crate::app::GroupBy::from_key(&val),
507            crate::app::GroupBy::None
508        );
509    }
510
511    #[test]
512    fn load_group_by_new_key_provider() {
513        let content = "group_by=provider\n";
514        let val = parse_value(content, "group_by").unwrap_or_default();
515        assert_eq!(
516            crate::app::GroupBy::from_key(&val),
517            crate::app::GroupBy::Provider
518        );
519    }
520
521    #[test]
522    fn load_group_by_new_key_tag() {
523        let content = "group_by=tag:production\n";
524        let val = parse_value(content, "group_by").unwrap_or_default();
525        assert_eq!(
526            crate::app::GroupBy::from_key(&val),
527            crate::app::GroupBy::Tag("production".to_string())
528        );
529    }
530
531    #[test]
532    fn load_group_by_backward_compat_true() {
533        let content = "group_by_provider=true\n";
534        let new_val = parse_value(content, "group_by");
535        let old_val = parse_value(content, "group_by_provider");
536        let result = if let Some(v) = new_val {
537            crate::app::GroupBy::from_key(&v)
538        } else if let Some(v) = old_val {
539            if v == "true" {
540                crate::app::GroupBy::Provider
541            } else {
542                crate::app::GroupBy::None
543            }
544        } else {
545            crate::app::GroupBy::None
546        };
547        assert_eq!(result, crate::app::GroupBy::Provider);
548    }
549
550    #[test]
551    fn load_group_by_backward_compat_false() {
552        let content = "group_by_provider=false\n";
553        let new_val = parse_value(content, "group_by");
554        let old_val = parse_value(content, "group_by_provider");
555        let result = if let Some(v) = new_val {
556            crate::app::GroupBy::from_key(&v)
557        } else if let Some(v) = old_val {
558            if v == "true" {
559                crate::app::GroupBy::Provider
560            } else {
561                crate::app::GroupBy::None
562            }
563        } else {
564            crate::app::GroupBy::None
565        };
566        assert_eq!(result, crate::app::GroupBy::None);
567    }
568
569    #[test]
570    fn load_group_by_new_key_overrides_old() {
571        let content = "group_by_provider=true\ngroup_by=tag:staging\n";
572        let new_val = parse_value(content, "group_by");
573        let old_val = parse_value(content, "group_by_provider");
574        let result = if let Some(v) = new_val {
575            crate::app::GroupBy::from_key(&v)
576        } else if let Some(v) = old_val {
577            if v == "true" {
578                crate::app::GroupBy::Provider
579            } else {
580                crate::app::GroupBy::None
581            }
582        } else {
583            crate::app::GroupBy::None
584        };
585        assert_eq!(result, crate::app::GroupBy::Tag("staging".to_string()));
586    }
587
588    #[test]
589    fn load_group_by_missing_defaults_to_provider() {
590        let content = "sort_mode=alpha\n";
591        let new_val = parse_value(content, "group_by");
592        let old_val = parse_value(content, "group_by_provider");
593        let result = if let Some(v) = new_val {
594            crate::app::GroupBy::from_key(&v)
595        } else if let Some(v) = old_val {
596            if v == "true" {
597                crate::app::GroupBy::Provider
598            } else {
599                crate::app::GroupBy::None
600            }
601        } else {
602            crate::app::GroupBy::Provider
603        };
604        assert_eq!(result, crate::app::GroupBy::Provider);
605    }
606
607    #[test]
608    fn save_group_by_format() {
609        let key = "group_by";
610        let value = crate::app::GroupBy::Tag("production".to_string()).to_key();
611        let line = format!("{}={}", key, value);
612        assert_eq!(line, "group_by=tag:production");
613    }
614
615    #[test]
616    fn save_value_appends_new_key() {
617        let existing = "sort_mode=alpha\n";
618        let key = "askpass";
619        let new_value = "keychain";
620
621        let mut lines: Vec<String> = Vec::new();
622        let mut found = false;
623        for line in existing.lines() {
624            let trimmed = line.trim();
625            if !trimmed.starts_with('#')
626                && !trimmed.is_empty()
627                && trimmed
628                    .split_once('=')
629                    .is_some_and(|(k, _)| k.trim() == key)
630            {
631                lines.push(format!("{}={}", key, new_value));
632                found = true;
633            } else {
634                lines.push(line.to_string());
635            }
636        }
637        if !found {
638            lines.push(format!("{}={}", key, new_value));
639        }
640        let content = lines.join("\n") + "\n";
641        assert!(content.contains("askpass=keychain"));
642        assert!(content.contains("sort_mode=alpha"));
643        assert!(!found); // Was appended, not replaced
644    }
645
646    // --- Real file I/O tests against a per-test temp Paths ---
647    //
648    // Each test gets its own tempfile::tempdir() via with_temp_prefs, so the
649    // tests are isolated and need no shared lock.
650
651    fn with_temp_prefs<F: FnOnce(&crate::runtime::env::Paths)>(f: F) {
652        super::tests_helpers::with_temp_prefs(f);
653    }
654
655    #[test]
656    fn save_and_load_group_by_roundtrip_tag() {
657        with_temp_prefs(|paths| {
658            let mode = crate::app::GroupBy::Tag("production".to_string());
659            save_group_by(Some(paths), &mode).unwrap();
660            let loaded = load_group_by(Some(paths));
661            assert_eq!(loaded, crate::app::GroupBy::Tag("production".to_string()));
662        });
663    }
664
665    #[test]
666    fn ssh_command_roundtrip_and_empty_means_unset() {
667        with_temp_prefs(|paths| {
668            assert_eq!(load_ssh_command(Some(paths)), None);
669            save_ssh_command(Some(paths), "kitten ssh").unwrap();
670            assert_eq!(
671                load_ssh_command(Some(paths)),
672                Some("kitten ssh".to_string())
673            );
674            // A blank value reads as unset so the default `ssh` applies.
675            save_ssh_command(Some(paths), "").unwrap();
676            assert_eq!(load_ssh_command(Some(paths)), None);
677            std::fs::write(paths.preferences(), "ssh_command=   \ntheme=purple\n").unwrap();
678            assert_eq!(load_ssh_command(Some(paths)), None);
679        });
680    }
681
682    #[test]
683    fn save_and_load_group_by_roundtrip_provider() {
684        with_temp_prefs(|paths| {
685            save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
686            let loaded = load_group_by(Some(paths));
687            assert_eq!(loaded, crate::app::GroupBy::Provider);
688        });
689    }
690
691    #[test]
692    fn save_and_load_group_by_roundtrip_none() {
693        with_temp_prefs(|paths| {
694            save_group_by(Some(paths), &crate::app::GroupBy::None).unwrap();
695            let loaded = load_group_by(Some(paths));
696            assert_eq!(loaded, crate::app::GroupBy::None);
697        });
698    }
699
700    #[test]
701    fn save_group_by_removes_legacy_key() {
702        with_temp_prefs(|paths| {
703            let path = paths.preferences();
704            std::fs::write(&path, "group_by_provider=true\nsort_mode=alpha\n").unwrap();
705            save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
706            let content = std::fs::read_to_string(&path).unwrap();
707            assert!(
708                content.contains("group_by=provider"),
709                "new key should exist"
710            );
711            assert!(
712                !content.contains("group_by_provider"),
713                "legacy key should be removed"
714            );
715            assert!(content.contains("sort_mode=alpha"), "other keys preserved");
716        });
717    }
718
719    #[test]
720    fn load_group_by_backward_compat_real_file() {
721        with_temp_prefs(|paths| {
722            std::fs::write(paths.preferences(), "group_by_provider=true\n").unwrap();
723            let loaded = load_group_by(Some(paths));
724            assert_eq!(loaded, crate::app::GroupBy::Provider);
725        });
726    }
727
728    #[test]
729    fn load_group_by_empty_file_defaults_to_provider() {
730        with_temp_prefs(|paths| {
731            std::fs::write(paths.preferences(), "").unwrap();
732            let loaded = load_group_by(Some(paths));
733            assert_eq!(loaded, crate::app::GroupBy::Provider);
734        });
735    }
736
737    #[test]
738    fn load_group_by_missing_file_defaults_to_provider() {
739        with_temp_prefs(|paths| {
740            // The preferences file is never created in the temp dir, so this
741            // exercises the missing-file path.
742            assert!(!paths.preferences().exists());
743            let loaded = load_group_by(Some(paths));
744            assert_eq!(loaded, crate::app::GroupBy::Provider);
745        });
746    }
747
748    #[test]
749    fn save_group_by_tag_with_special_chars_roundtrip() {
750        with_temp_prefs(|paths| {
751            let mode = crate::app::GroupBy::Tag("us-east-1".to_string());
752            save_group_by(Some(paths), &mode).unwrap();
753            let loaded = load_group_by(Some(paths));
754            assert_eq!(loaded, crate::app::GroupBy::Tag("us-east-1".to_string()));
755        });
756    }
757
758    #[test]
759    fn save_group_by_preserves_other_prefs() {
760        with_temp_prefs(|paths| {
761            let path = paths.preferences();
762            std::fs::write(&path, "sort_mode=alpha\nview_mode=detailed\n").unwrap();
763            save_group_by(
764                Some(paths),
765                &crate::app::GroupBy::Tag("staging".to_string()),
766            )
767            .unwrap();
768            let content = std::fs::read_to_string(&path).unwrap();
769            assert!(content.contains("sort_mode=alpha"), "sort_mode preserved");
770            assert!(
771                content.contains("view_mode=detailed"),
772                "view_mode preserved"
773            );
774            assert!(content.contains("group_by=tag:staging"), "group_by written");
775        });
776    }
777
778    #[test]
779    fn remove_value_noop_when_key_not_present() {
780        let content = "sort_mode=alpha\nview_mode=compact\n";
781        let lines: Vec<&str> = content.lines().collect();
782        let has_key = lines.iter().any(|line| {
783            let trimmed = line.trim();
784            !trimmed.starts_with('#')
785                && !trimmed.is_empty()
786                && trimmed
787                    .split_once('=')
788                    .is_some_and(|(k, _)| k.trim() == "nonexistent")
789        });
790        assert!(!has_key);
791    }
792
793    #[test]
794    fn remove_value_preserves_comments_and_empty_lines() {
795        let content = "# comment\n\nsort_mode=alpha\ngroup_by_provider=true\nview_mode=compact\n";
796        let key = "group_by_provider";
797        let lines: Vec<String> = content
798            .lines()
799            .filter(|line| {
800                let trimmed = line.trim();
801                if trimmed.starts_with('#') || trimmed.is_empty() {
802                    return true;
803                }
804                trimmed.split_once('=').is_none_or(|(k, _)| k.trim() != key)
805            })
806            .map(|l| l.to_string())
807            .collect();
808        let result = lines.join("\n") + "\n";
809        assert!(result.contains("# comment"));
810        assert!(result.contains("sort_mode=alpha"));
811        assert!(result.contains("view_mode=compact"));
812        assert!(!result.contains("group_by_provider"));
813    }
814
815    #[test]
816    fn remove_value_handles_key_as_only_line() {
817        let content = "group_by_provider=true\n";
818        let key = "group_by_provider";
819        let lines: Vec<String> = content
820            .lines()
821            .filter(|line| {
822                let trimmed = line.trim();
823                if trimmed.starts_with('#') || trimmed.is_empty() {
824                    return true;
825                }
826                trimmed.split_once('=').is_none_or(|(k, _)| k.trim() != key)
827            })
828            .map(|l| l.to_string())
829            .collect();
830        let result = lines.join("\n") + "\n";
831        assert!(!result.contains("group_by_provider"));
832    }
833
834    #[test]
835    fn remove_value_real_file_io() {
836        with_temp_prefs(|paths| {
837            let path = paths.preferences();
838            std::fs::write(
839                &path,
840                "sort_mode=alpha\ngroup_by_provider=true\nview_mode=compact\n",
841            )
842            .unwrap();
843            // save_group_by calls remove_value("group_by_provider") internally
844            save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
845            let content = std::fs::read_to_string(&path).unwrap();
846            assert!(!content.contains("group_by_provider"));
847            assert!(content.contains("sort_mode=alpha"));
848            assert!(content.contains("view_mode=compact"));
849        });
850    }
851
852    #[test]
853    fn remove_value_noop_real_file_io() {
854        with_temp_prefs(|paths| {
855            let path = paths.preferences();
856            std::fs::write(&path, "sort_mode=alpha\n").unwrap();
857            let before = std::fs::read_to_string(&path).unwrap();
858            // save_group_by calls remove_value("group_by_provider"), which should be a no-op
859            // since the key doesn't exist. We save Provider to trigger the remove path.
860            save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
861            let after = std::fs::read_to_string(&path).unwrap();
862            // The file will have group_by=provider added, but group_by_provider should
863            // not have been written and removed (no-op path exercised)
864            assert!(after.contains("sort_mode=alpha"));
865            assert!(!before.contains("group_by_provider"));
866            assert!(!after.contains("group_by_provider"));
867        });
868    }
869
870    // --- View mode defaults ---
871
872    #[test]
873    fn load_view_mode_defaults_to_detailed() {
874        with_temp_prefs(|paths| {
875            // No preferences file content written.
876            // load_view_mode reads "view_mode" key; missing -> Detailed
877            let mode = load_view_mode(Some(paths));
878            assert_eq!(mode, ViewMode::Detailed);
879        });
880    }
881
882    #[test]
883    fn load_view_mode_explicit_compact() {
884        with_temp_prefs(|paths| {
885            std::fs::write(paths.preferences(), "view_mode=compact\n").unwrap();
886            let mode = load_view_mode(Some(paths));
887            assert_eq!(mode, ViewMode::Compact);
888        });
889    }
890
891    // --- Containers sort mode (separate key from host-list sort_mode) ---
892
893    #[test]
894    fn load_containers_sort_mode_defaults_to_alpha_host() {
895        with_temp_prefs(|paths| {
896            assert_eq!(
897                load_containers_sort_mode(Some(paths)),
898                ContainersSortMode::AlphaHost
899            );
900        });
901    }
902
903    #[test]
904    fn save_load_containers_sort_mode_round_trip() {
905        with_temp_prefs(|paths| {
906            save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaContainer).unwrap();
907            assert_eq!(
908                load_containers_sort_mode(Some(paths)),
909                ContainersSortMode::AlphaContainer
910            );
911            save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaHost).unwrap();
912            assert_eq!(
913                load_containers_sort_mode(Some(paths)),
914                ContainersSortMode::AlphaHost
915            );
916        });
917    }
918
919    #[test]
920    fn load_containers_sort_mode_unknown_value_falls_back_to_default() {
921        with_temp_prefs(|paths| {
922            std::fs::write(paths.preferences(), "containers_sort_mode=garbage\n").unwrap();
923            assert_eq!(
924                load_containers_sort_mode(Some(paths)),
925                ContainersSortMode::AlphaHost
926            );
927        });
928    }
929
930    #[test]
931    fn containers_sort_mode_does_not_clobber_host_sort_mode() {
932        with_temp_prefs(|paths| {
933            save_sort_mode(Some(paths), SortMode::AlphaAlias).unwrap();
934            save_containers_sort_mode(Some(paths), ContainersSortMode::AlphaContainer).unwrap();
935            let content = std::fs::read_to_string(paths.preferences()).unwrap();
936            assert!(content.contains("sort_mode=alpha_alias"));
937            assert!(content.contains("containers_sort_mode=alpha_container"));
938            assert_eq!(load_sort_mode(Some(paths)), SortMode::AlphaAlias);
939            assert_eq!(
940                load_containers_sort_mode(Some(paths)),
941                ContainersSortMode::AlphaContainer
942            );
943        });
944    }
945
946    // --- Containers view mode (separate key from host-list view_mode) ---
947
948    #[test]
949    fn load_containers_view_mode_defaults_to_detailed() {
950        with_temp_prefs(|paths| {
951            assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Detailed);
952        });
953    }
954
955    #[test]
956    fn save_load_containers_view_mode_round_trip() {
957        with_temp_prefs(|paths| {
958            save_containers_view_mode(Some(paths), ViewMode::Compact).unwrap();
959            assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Compact);
960            save_containers_view_mode(Some(paths), ViewMode::Detailed).unwrap();
961            assert_eq!(load_containers_view_mode(Some(paths)), ViewMode::Detailed);
962        });
963    }
964
965    #[test]
966    fn save_containers_collapsed_hosts_writes_sorted_csv() {
967        with_temp_prefs(|paths| {
968            let mut set = std::collections::HashSet::new();
969            set.insert("zeus".to_string());
970            set.insert("apollo".to_string());
971            set.insert("hera".to_string());
972            save_containers_collapsed_hosts(Some(paths), &set).unwrap();
973            let content = std::fs::read_to_string(paths.preferences()).unwrap();
974            // Sorted output keeps the prefs file diff-friendly across runs.
975            assert!(content.contains("containers_collapsed_hosts=apollo,hera,zeus"));
976        });
977    }
978
979    #[test]
980    fn save_containers_collapsed_hosts_empty_clears_key() {
981        with_temp_prefs(|paths| {
982            let path = paths.preferences();
983            std::fs::write(&path, "containers_collapsed_hosts=alpha\n").unwrap();
984            save_containers_collapsed_hosts(Some(paths), &std::collections::HashSet::new())
985                .unwrap();
986            let content = std::fs::read_to_string(&path).unwrap();
987            assert!(
988                !content.contains("containers_collapsed_hosts"),
989                "empty set must remove the key entirely"
990            );
991        });
992    }
993
994    #[test]
995    fn load_containers_collapsed_hosts_round_trip() {
996        with_temp_prefs(|paths| {
997            let mut set = std::collections::HashSet::new();
998            set.insert("alpha".to_string());
999            set.insert("bravo".to_string());
1000            save_containers_collapsed_hosts(Some(paths), &set).unwrap();
1001            let loaded = load_containers_collapsed_hosts(Some(paths));
1002            assert_eq!(loaded, set);
1003        });
1004    }
1005
1006    // --- slow_threshold_ms ---
1007
1008    #[test]
1009    fn load_slow_threshold_default() {
1010        let content = "sort_mode=alpha\n";
1011        let val = parse_value(content, "slow_threshold_ms");
1012        let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
1013        assert_eq!(threshold, 200);
1014    }
1015
1016    #[test]
1017    fn load_slow_threshold_custom() {
1018        let content = "slow_threshold_ms=500\n";
1019        let val = parse_value(content, "slow_threshold_ms");
1020        let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
1021        assert_eq!(threshold, 500);
1022    }
1023
1024    #[test]
1025    fn load_auto_ping_default_true() {
1026        let content = "sort_mode=alpha\n";
1027        let val = parse_value(content, "auto_ping");
1028        let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1029        assert!(auto_ping);
1030    }
1031
1032    #[test]
1033    fn load_auto_ping_explicit_true() {
1034        let content = "auto_ping=true\n";
1035        let val = parse_value(content, "auto_ping");
1036        let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1037        assert!(auto_ping);
1038    }
1039
1040    #[test]
1041    fn save_and_load_slow_threshold_roundtrip() {
1042        with_temp_prefs(|paths| {
1043            save_slow_threshold(Some(paths), 500).unwrap();
1044            let loaded = load_slow_threshold(Some(paths));
1045            assert_eq!(loaded, 500);
1046        });
1047    }
1048
1049    #[test]
1050    fn auto_ping_roundtrip_true() {
1051        // Verify the auto_ping parse logic with the inline parse_value helper.
1052        // Pure parsing, no disk I/O needed for this assertion.
1053        let content = "auto_ping=true\n";
1054        let val = parse_value(content, "auto_ping");
1055        assert_eq!(val.as_deref(), Some("true"));
1056        // Confirm load_auto_ping's parsing logic: anything != "false" → true
1057        assert!(val.map(|v| v != "false").unwrap_or(true));
1058    }
1059
1060    #[test]
1061    fn auto_ping_roundtrip_false() {
1062        let content = "auto_ping=false\n";
1063        let val = parse_value(content, "auto_ping");
1064        assert_eq!(val.as_deref(), Some("false"));
1065        // Confirm load_auto_ping's parsing logic: "false" → false
1066        assert!(!val.map(|v| v != "false").unwrap_or(true));
1067    }
1068
1069    #[test]
1070    fn load_slow_threshold_invalid_defaults() {
1071        let content = "slow_threshold_ms=abc\n";
1072        let val = parse_value(content, "slow_threshold_ms");
1073        let threshold: u16 = val.and_then(|v| v.parse().ok()).unwrap_or(200);
1074        assert_eq!(threshold, 200);
1075    }
1076
1077    #[test]
1078    fn save_and_load_theme_roundtrip() {
1079        with_temp_prefs(|paths| {
1080            save_theme(Some(paths), "catppuccin-mocha").unwrap();
1081            let loaded = load_theme(Some(paths));
1082            assert_eq!(loaded, Some("catppuccin-mocha".to_string()));
1083        });
1084    }
1085
1086    #[test]
1087    fn load_theme_missing_returns_none() {
1088        with_temp_prefs(|paths| {
1089            std::fs::write(paths.preferences(), "sort_mode=alpha\n").unwrap();
1090            let loaded = load_theme(Some(paths));
1091            assert_eq!(loaded, None);
1092        });
1093    }
1094
1095    #[test]
1096    fn load_auto_ping_explicit_false() {
1097        let content = "auto_ping=false\n";
1098        let val = parse_value(content, "auto_ping");
1099        let auto_ping = val.map(|v| v != "false").unwrap_or(true);
1100        assert!(!auto_ping);
1101    }
1102
1103    #[test]
1104    fn last_seen_version_round_trip() {
1105        with_temp_prefs(|paths| {
1106            save_last_seen_version(Some(paths), "2.41.0").unwrap();
1107            let loaded = load_last_seen_version(Some(paths)).unwrap();
1108            assert_eq!(loaded.as_deref(), Some("2.41.0"));
1109        });
1110    }
1111
1112    #[test]
1113    fn last_seen_version_returns_none_when_unset() {
1114        with_temp_prefs(|paths| {
1115            let loaded = load_last_seen_version(Some(paths)).unwrap();
1116            assert_eq!(loaded, None);
1117        });
1118    }
1119
1120    #[test]
1121    fn recovered_lock_survives_poison() {
1122        let lock: std::sync::Arc<std::sync::Mutex<Option<PathBuf>>> =
1123            std::sync::Arc::new(std::sync::Mutex::new(None));
1124        let poisoner = lock.clone();
1125        let joined = std::thread::spawn(move || {
1126            let _guard = poisoner.lock().unwrap();
1127            panic!("intentional poison for test");
1128        })
1129        .join();
1130        assert!(joined.is_err(), "poisoning thread must have panicked");
1131        assert!(lock.is_poisoned(), "mutex must be poisoned after panic");
1132
1133        // The poison-recovery idiom used wherever a shared Mutex guards cross-test state.
1134        let recovered = lock.lock().unwrap_or_else(|e| e.into_inner());
1135        assert!(
1136            recovered.is_none(),
1137            "recovered lock must expose inner value"
1138        );
1139    }
1140}