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            if k.trim() == key {
44                return Some(v.trim().to_string());
45            }
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#[cfg(test)]
381mod tests {
382    use super::*;
383
384    // We test load_value/save_value logic by replicating the parsing inline,
385    // since the real functions read from ~/.purple/preferences.
386
387    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        // Verify the format that save_value produces
447        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        // Simulate save_value logic
456        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); // Was appended, not replaced
629    }
630
631    // --- Real file I/O tests against a per-test temp Paths ---
632    //
633    // Each test gets its own tempfile::tempdir() via with_temp_prefs, so the
634    // tests are isolated and need no shared lock.
635
636    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            // The preferences file is never created in the temp dir, so this
709            // exercises the missing-file path.
710            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 calls remove_value("group_by_provider") internally
812            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 calls remove_value("group_by_provider"), which should be a no-op
827            // since the key doesn't exist. We save Provider to trigger the remove path.
828            save_group_by(Some(paths), &crate::app::GroupBy::Provider).unwrap();
829            let after = std::fs::read_to_string(&path).unwrap();
830            // The file will have group_by=provider added, but group_by_provider should
831            // not have been written and removed (no-op path exercised)
832            assert!(after.contains("sort_mode=alpha"));
833            assert!(!before.contains("group_by_provider"));
834            assert!(!after.contains("group_by_provider"));
835        });
836    }
837
838    // --- View mode defaults ---
839
840    #[test]
841    fn load_view_mode_defaults_to_detailed() {
842        with_temp_prefs(|paths| {
843            // No preferences file content written.
844            // load_view_mode reads "view_mode" key; missing -> Detailed
845            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    // --- Containers sort mode (separate key from host-list sort_mode) ---
860
861    #[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    // --- Containers view mode (separate key from host-list view_mode) ---
915
916    #[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            // Sorted output keeps the prefs file diff-friendly across runs.
943            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    // --- slow_threshold_ms ---
975
976    #[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        // Verify the auto_ping parse logic with the inline parse_value helper.
1020        // Pure parsing, no disk I/O needed for this assertion.
1021        let content = "auto_ping=true\n";
1022        let val = parse_value(content, "auto_ping");
1023        assert_eq!(val.as_deref(), Some("true"));
1024        // Confirm load_auto_ping's parsing logic: anything != "false" → true
1025        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        // Confirm load_auto_ping's parsing logic: "false" → false
1034        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        // The poison-recovery idiom used wherever a shared Mutex guards cross-test state.
1102        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}