Skip to main content

tui_file_explorer/
persistence.rs

1//! Persist application state between sessions.
2//!
3//! State is stored at `$XDG_CONFIG_HOME/tfe/state` (falling back to
4//! `~/.config/tfe/state`) as a plain `KEY=VALUE` text file:
5//!
6//! ```text
7//! # tfe state — do not edit manually
8//! theme=grape
9//! last_dir=/home/user/projects
10//! sort_mode=name
11//! show_hidden=false
12//! single_pane=false
13//! ```
14//!
15//! Unknown keys are silently ignored so that older versions of the binary can
16//! read state files written by newer ones without errors.  Malformed lines
17//! (no `=` separator, blank, or comment) are also skipped gracefully.
18//!
19//! # Backward compatibility
20//!
21//! Older versions of `tfe` stored only the theme name in a separate
22//! `$XDG_CONFIG_HOME/tfe/theme` file.  [`load_state`] falls back to that file
23//! when the new `state` file is absent, so upgrades from older versions are
24//! transparent.
25
26use std::{
27    fs, io,
28    path::{Path, PathBuf},
29};
30
31use crate::{SortMode, Theme};
32
33// ── Key constants ─────────────────────────────────────────────────────────────
34
35const KEY_THEME: &str = "theme";
36const KEY_LAST_DIR: &str = "last_dir";
37const KEY_LAST_DIR_RIGHT: &str = "last_dir_right";
38const KEY_SORT_MODE: &str = "sort_mode";
39const KEY_SHOW_HIDDEN: &str = "show_hidden";
40const KEY_SINGLE_PANE: &str = "single_pane";
41const KEY_CD_ON_EXIT: &str = "cd_on_exit";
42const KEY_EDITOR: &str = "editor";
43
44// ── AppState ──────────────────────────────────────────────────────────────────
45
46/// All application state that is persisted between sessions.
47///
48/// Every field is an `Option` so that absent keys are handled gracefully —
49/// the caller provides a sensible default for any field that is `None`.
50///
51/// # Example
52///
53/// ```rust,ignore
54/// use crate::persistence::{AppState, load_state, save_state};
55/// use tui_file_explorer::SortMode;
56///
57/// let mut state = load_state();
58/// state.theme      = Some("nord".into());
59/// state.sort_mode  = Some(SortMode::SizeDesc);
60/// state.show_hidden = Some(true);
61/// save_state(&state);
62/// ```
63#[derive(Debug, Default, Clone, PartialEq, Eq)]
64pub struct AppState {
65    /// Colour theme name (e.g. `"grape"`, `"nord"`, `"catppuccin-mocha"`).
66    pub theme: Option<String>,
67
68    /// Directory that was open in the left pane when the app last exited.
69    ///
70    /// Only restored when the path still exists as a directory; stale entries
71    /// (deleted directories) are silently ignored.
72    pub last_dir: Option<PathBuf>,
73
74    /// Directory that was open in the right pane when the app last exited.
75    ///
76    /// Only restored when the path still exists as a directory; stale entries
77    /// (deleted directories) are silently ignored.
78    pub last_dir_right: Option<PathBuf>,
79
80    /// Active sort mode: `Name`, `SizeDesc`, or `Extension`.
81    pub sort_mode: Option<SortMode>,
82
83    /// Whether hidden (dot-prefixed) files were visible.
84    pub show_hidden: Option<bool>,
85
86    /// Whether single-pane mode was active.
87    pub single_pane: Option<bool>,
88
89    /// Whether the cd-on-exit feature is enabled.
90    ///
91    /// When `true`, `tfe` prints the active pane's current directory to stdout
92    /// on dismiss so the shell wrapper can `cd` to it.  When `false` (default),
93    /// dismissing without a selection prints nothing and exits with code 1.
94    pub cd_on_exit: Option<bool>,
95
96    /// The editor to use when the user presses `e` on a file.
97    ///
98    /// Serialised as a short key string (e.g. `"helix"`, `"nvim"`,
99    /// `"custom:code"`).  `None` means "use the compiled-in default" (Helix).
100    pub editor: Option<String>,
101}
102
103// ── Config-dir helpers ────────────────────────────────────────────────────────
104
105/// Returns the `tfe` config directory, following XDG conventions.
106///
107/// Priority: `$XDG_CONFIG_HOME/tfe` → `$HOME/.config/tfe` → `None`.
108fn config_dir() -> Option<PathBuf> {
109    let base = std::env::var_os("XDG_CONFIG_HOME")
110        .map(PathBuf::from)
111        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
112    Some(base.join("tfe"))
113}
114
115/// Path of the unified state file (`$XDG_CONFIG_HOME/tfe/state`).
116pub fn state_path() -> Option<PathBuf> {
117    config_dir().map(|d| d.join("state"))
118}
119
120/// Path of the legacy theme-only file (`$XDG_CONFIG_HOME/tfe/theme`).
121///
122/// This file was written by older versions of `tfe`. It is only ever *read*
123/// (as a fallback) by the current version; all writes go to `state`.
124pub(crate) fn legacy_theme_path() -> Option<PathBuf> {
125    config_dir().map(|d| d.join("theme"))
126}
127
128// ── SortMode serialisation helpers ───────────────────────────────────────────
129
130/// Convert a `SortMode` to its stable on-disk key string.
131fn sort_mode_to_key(mode: SortMode) -> &'static str {
132    match mode {
133        SortMode::Name => "name",
134        SortMode::SizeDesc => "size_desc",
135        SortMode::Extension => "extension",
136    }
137}
138
139/// Parse a `SortMode` from its on-disk key string.
140///
141/// Returns `None` for unrecognised values so that the field is left as `None`
142/// rather than silently defaulting to `Name`.
143fn sort_mode_from_key(s: &str) -> Option<SortMode> {
144    match s {
145        "name" => Some(SortMode::Name),
146        "size_desc" => Some(SortMode::SizeDesc),
147        "extension" => Some(SortMode::Extension),
148        _ => None,
149    }
150}
151
152// ── Low-level I/O ─────────────────────────────────────────────────────────────
153
154/// Parse a `KEY=VALUE` state file at `path` into an [`AppState`].
155///
156/// * Any I/O error (e.g. missing file) yields a default empty state.
157/// * Blank lines and lines starting with `#` are skipped.
158/// * Lines without a `=` separator are skipped.
159/// * Unknown keys are silently ignored (forward-compatibility).
160/// * The `last_dir` field is only populated when the path is an existing
161///   directory — stale entries are discarded rather than propagated.
162pub(crate) fn load_state_from(path: &Path) -> AppState {
163    let Ok(content) = fs::read_to_string(path) else {
164        return AppState::default();
165    };
166
167    let mut state = AppState::default();
168
169    for raw_line in content.lines() {
170        let line = raw_line.trim();
171
172        // Skip comments and blank lines.
173        if line.is_empty() || line.starts_with('#') {
174            continue;
175        }
176
177        // Split on the *first* `=` only so that paths containing `=` are safe.
178        let Some((key, value)) = line.split_once('=') else {
179            continue;
180        };
181        let (key, value) = (key.trim(), value.trim());
182
183        match key {
184            KEY_THEME if !value.is_empty() => {
185                state.theme = Some(value.to_string());
186            }
187            KEY_LAST_DIR if !value.is_empty() => {
188                let p = PathBuf::from(value);
189                // Only restore if the directory still exists on disk.
190                if p.is_dir() {
191                    state.last_dir = Some(p);
192                }
193            }
194            KEY_LAST_DIR_RIGHT if !value.is_empty() => {
195                let p = PathBuf::from(value);
196                // Only restore if the directory still exists on disk.
197                if p.is_dir() {
198                    state.last_dir_right = Some(p);
199                }
200            }
201            KEY_SORT_MODE => {
202                state.sort_mode = sort_mode_from_key(value);
203            }
204            KEY_SHOW_HIDDEN => {
205                state.show_hidden = value.parse::<bool>().ok();
206            }
207            KEY_SINGLE_PANE => {
208                state.single_pane = value.parse::<bool>().ok();
209            }
210            KEY_CD_ON_EXIT => {
211                state.cd_on_exit = value.parse::<bool>().ok();
212            }
213            KEY_EDITOR if !value.is_empty() => {
214                state.editor = Some(value.to_string());
215            }
216            _ => {
217                // Forward-compatible: unknown keys are silently ignored.
218            }
219        }
220    }
221
222    state
223}
224
225/// Serialise `state` to a `KEY=VALUE` file at `path`.
226///
227/// Parent directories are created automatically.  Only fields that are `Some`
228/// are written, so the file only ever contains meaningful values.
229pub(crate) fn save_state_to(path: &Path, state: &AppState) -> io::Result<()> {
230    if let Some(parent) = path.parent() {
231        fs::create_dir_all(parent)?;
232    }
233
234    let mut out = String::from("# tfe state — do not edit manually\n");
235
236    if let Some(ref theme) = state.theme {
237        out.push_str(&format!("{KEY_THEME}={theme}\n"));
238    }
239    if let Some(ref dir) = state.last_dir {
240        out.push_str(&format!("{KEY_LAST_DIR}={}\n", dir.display()));
241    }
242    if let Some(ref dir) = state.last_dir_right {
243        out.push_str(&format!("{KEY_LAST_DIR_RIGHT}={}\n", dir.display()));
244    }
245    if let Some(mode) = state.sort_mode {
246        out.push_str(&format!("{KEY_SORT_MODE}={}\n", sort_mode_to_key(mode)));
247    }
248    if let Some(hidden) = state.show_hidden {
249        out.push_str(&format!("{KEY_SHOW_HIDDEN}={hidden}\n"));
250    }
251    if let Some(single) = state.single_pane {
252        out.push_str(&format!("{KEY_SINGLE_PANE}={single}\n"));
253    }
254    if let Some(cd) = state.cd_on_exit {
255        out.push_str(&format!("{KEY_CD_ON_EXIT}={cd}\n"));
256    }
257    if let Some(ref editor) = state.editor {
258        out.push_str(&format!("{KEY_EDITOR}={editor}\n"));
259    }
260
261    fs::write(path, out)
262}
263
264// ── Public API ────────────────────────────────────────────────────────────────
265
266/// Load application state from the default XDG config path.
267///
268/// Falls back to the legacy `tfe/theme` file when the new `state` file is
269/// absent, providing a seamless upgrade from older `tfe` versions.
270///
271/// Never returns an error — any I/O problem simply yields an empty state so
272/// that the app can always start with sensible defaults.
273pub fn load_state() -> AppState {
274    if let Some(path) = state_path() {
275        if path.exists() {
276            return load_state_from(&path);
277        }
278    }
279
280    // ── Legacy fallback ───────────────────────────────────────────────────────
281    // Older versions of `tfe` only persisted the theme name, in a file called
282    // `tfe/theme`.  Read it here so the user's theme choice is preserved after
283    // upgrading.
284    let mut state = AppState::default();
285    if let Some(legacy) = legacy_theme_path() {
286        if let Ok(raw) = fs::read_to_string(&legacy) {
287            let name = raw.trim().to_string();
288            if !name.is_empty() {
289                state.theme = Some(name);
290            }
291        }
292    }
293    state
294}
295
296/// Persist `state` to the default XDG config path.
297///
298/// Errors are silently discarded — persistence is best-effort and must never
299/// cause the application to crash or block.
300pub fn save_state(state: &AppState) {
301    if let Some(path) = state_path() {
302        let _ = save_state_to(&path, state);
303    }
304}
305
306// ── Theme resolution ──────────────────────────────────────────────────────────
307
308/// Find the index into `themes` whose name matches `name`.
309///
310/// Matching is **case-insensitive** and treats **hyphens as spaces**, so
311/// `"catppuccin-mocha"` matches `"Catppuccin Mocha"`.
312///
313/// Returns `0` (the built-in default theme) when no match is found, and
314/// prints a hint to stderr suggesting `--list-themes`.
315pub fn resolve_theme_idx(name: &str, themes: &[(&str, &str, Theme)]) -> usize {
316    let key = name.to_lowercase().replace('-', " ");
317    for (i, (n, _, _)) in themes.iter().enumerate() {
318        if n.to_lowercase().replace('-', " ") == key {
319            return i;
320        }
321    }
322    eprintln!(
323        "tfe: unknown theme {:?} — falling back to default. \
324         Run `tfe --list-themes` to see available options.",
325        name
326    );
327    0
328}
329
330// ── Tests ─────────────────────────────────────────────────────────────────────
331
332#[cfg(test)]
333mod tests {
334    // ── sort_mode_to_key / sort_mode_from_key ─────────────────────────────────
335
336    #[test]
337    fn sort_mode_to_key_name() {
338        assert_eq!(sort_mode_to_key(SortMode::Name), "name");
339    }
340
341    #[test]
342    fn sort_mode_to_key_size_desc() {
343        assert_eq!(sort_mode_to_key(SortMode::SizeDesc), "size_desc");
344    }
345
346    #[test]
347    fn sort_mode_to_key_extension() {
348        assert_eq!(sort_mode_to_key(SortMode::Extension), "extension");
349    }
350
351    #[test]
352    fn sort_mode_from_key_name() {
353        assert_eq!(sort_mode_from_key("name"), Some(SortMode::Name));
354    }
355
356    #[test]
357    fn sort_mode_from_key_size_desc() {
358        assert_eq!(sort_mode_from_key("size_desc"), Some(SortMode::SizeDesc));
359    }
360
361    #[test]
362    fn sort_mode_from_key_extension() {
363        assert_eq!(sort_mode_from_key("extension"), Some(SortMode::Extension));
364    }
365
366    #[test]
367    fn sort_mode_from_key_unknown_returns_none() {
368        assert_eq!(sort_mode_from_key("bogus"), None);
369        assert_eq!(sort_mode_from_key(""), None);
370        assert_eq!(sort_mode_from_key("SIZE_DESC"), None);
371    }
372
373    #[test]
374    fn sort_mode_key_round_trips_all_variants() {
375        for mode in [SortMode::Name, SortMode::SizeDesc, SortMode::Extension] {
376            let key = sort_mode_to_key(mode);
377            let back = sort_mode_from_key(key);
378            assert_eq!(back, Some(mode), "round-trip failed for {mode:?}");
379        }
380    }
381
382    // ── AppState default ──────────────────────────────────────────────────────
383
384    #[test]
385    fn app_state_default_all_fields_none() {
386        let state = AppState::default();
387        assert!(state.theme.is_none());
388        assert!(state.last_dir.is_none());
389        assert!(state.last_dir_right.is_none());
390        assert!(state.sort_mode.is_none());
391        assert!(state.show_hidden.is_none());
392        assert!(state.single_pane.is_none());
393        assert!(state.cd_on_exit.is_none());
394    }
395
396    #[test]
397    fn app_state_default_equals_default() {
398        assert_eq!(AppState::default(), AppState::default());
399    }
400
401    #[test]
402    fn app_state_clone_equals_original() {
403        let state = AppState {
404            theme: Some("nord".into()),
405            show_hidden: Some(true),
406            ..Default::default()
407        };
408        assert_eq!(state.clone(), state);
409    }
410
411    use super::*;
412    use std::fs;
413    use tempfile::TempDir;
414
415    // ── Helpers ───────────────────────────────────────────────────────────────
416
417    /// Create a temp directory and return a path inside it for the state file.
418    /// The returned `TempDir` must be kept alive for the duration of the test.
419    fn tmp_state_path() -> (TempDir, PathBuf) {
420        let dir = tempfile::tempdir().expect("create temp dir");
421        let path = dir.path().join("tfe").join("state");
422        (dir, path)
423    }
424
425    /// Create a temp directory and return a path inside it for the legacy
426    /// theme file.
427    fn tmp_theme_path() -> (TempDir, PathBuf) {
428        let dir = tempfile::tempdir().expect("create temp dir");
429        let path = dir.path().join("tfe").join("theme");
430        (dir, path)
431    }
432
433    // ── save_state_to / load_state_from ───────────────────────────────────────
434
435    #[test]
436    fn full_state_round_trips() {
437        let (_dir, path) = tmp_state_path();
438        let original = AppState {
439            theme: Some("grape".into()),
440            last_dir: Some(std::env::temp_dir()),
441            last_dir_right: Some(std::env::temp_dir()),
442            sort_mode: Some(SortMode::SizeDesc),
443            show_hidden: Some(true),
444            single_pane: Some(false),
445            cd_on_exit: Some(true),
446            editor: Some("nvim".into()),
447        };
448        save_state_to(&path, &original).unwrap();
449        let loaded = load_state_from(&path);
450        assert_eq!(loaded, original);
451    }
452
453    #[test]
454    fn partial_state_leaves_absent_fields_as_none() {
455        let (_dir, path) = tmp_state_path();
456        let partial = AppState {
457            theme: Some("nord".into()),
458            ..Default::default()
459        };
460        save_state_to(&path, &partial).unwrap();
461        let loaded = load_state_from(&path);
462        assert_eq!(loaded.theme, Some("nord".into()));
463        assert!(loaded.last_dir.is_none());
464        assert!(loaded.sort_mode.is_none());
465        assert!(loaded.show_hidden.is_none());
466        assert!(loaded.single_pane.is_none());
467        assert!(loaded.cd_on_exit.is_none());
468    }
469
470    #[test]
471    fn cd_on_exit_true_round_trips() {
472        let (_dir, path) = tmp_state_path();
473        let state = AppState {
474            cd_on_exit: Some(true),
475            ..Default::default()
476        };
477        save_state_to(&path, &state).unwrap();
478        let loaded = load_state_from(&path);
479        assert_eq!(loaded.cd_on_exit, Some(true));
480    }
481
482    #[test]
483    fn cd_on_exit_false_round_trips() {
484        let (_dir, path) = tmp_state_path();
485        let state = AppState {
486            cd_on_exit: Some(false),
487            ..Default::default()
488        };
489        save_state_to(&path, &state).unwrap();
490        let loaded = load_state_from(&path);
491        assert_eq!(loaded.cd_on_exit, Some(false));
492    }
493
494    #[test]
495    fn missing_file_returns_default_state() {
496        let dir = tempfile::tempdir().unwrap();
497        let path = dir.path().join("nonexistent").join("state");
498        assert_eq!(load_state_from(&path), AppState::default());
499    }
500
501    #[test]
502    fn empty_file_returns_default_state() {
503        let (_dir, path) = tmp_state_path();
504        fs::create_dir_all(path.parent().unwrap()).unwrap();
505        fs::write(&path, "").unwrap();
506        assert_eq!(load_state_from(&path), AppState::default());
507    }
508
509    #[test]
510    fn save_state_creates_parent_directories() {
511        let (_dir, path) = tmp_state_path();
512        assert!(
513            !path.parent().unwrap().exists(),
514            "parent should not exist yet"
515        );
516        save_state_to(&path, &AppState::default()).unwrap();
517        assert!(path.exists(), "state file should have been created");
518    }
519
520    #[test]
521    fn save_state_overwrites_previous_content() {
522        let (_dir, path) = tmp_state_path();
523        let first = AppState {
524            theme: Some("grape".into()),
525            ..Default::default()
526        };
527        let second = AppState {
528            theme: Some("ocean".into()),
529            ..Default::default()
530        };
531        save_state_to(&path, &first).unwrap();
532        save_state_to(&path, &second).unwrap();
533        assert_eq!(load_state_from(&path).theme, Some("ocean".into()));
534    }
535
536    // ── File format edge cases ────────────────────────────────────────────────
537
538    #[test]
539    fn comment_lines_are_ignored() {
540        let (_dir, path) = tmp_state_path();
541        fs::create_dir_all(path.parent().unwrap()).unwrap();
542        fs::write(&path, "# tfe state\n# another comment\ntheme=dracula\n").unwrap();
543        assert_eq!(load_state_from(&path).theme, Some("dracula".into()));
544    }
545
546    #[test]
547    fn blank_lines_are_ignored() {
548        let (_dir, path) = tmp_state_path();
549        fs::create_dir_all(path.parent().unwrap()).unwrap();
550        fs::write(&path, "\n\ntheme=nord\n\nsort_mode=name\n\n").unwrap();
551        let state = load_state_from(&path);
552        assert_eq!(state.theme, Some("nord".into()));
553        assert_eq!(state.sort_mode, Some(SortMode::Name));
554    }
555
556    #[test]
557    fn unknown_keys_are_silently_ignored() {
558        let (_dir, path) = tmp_state_path();
559        fs::create_dir_all(path.parent().unwrap()).unwrap();
560        fs::write(
561            &path,
562            "theme=nord\nfuture_feature=42\nanother_new_key=xyz\n",
563        )
564        .unwrap();
565        let state = load_state_from(&path);
566        assert_eq!(state.theme, Some("nord".into()));
567    }
568
569    #[test]
570    fn malformed_lines_without_equals_are_skipped() {
571        let (_dir, path) = tmp_state_path();
572        fs::create_dir_all(path.parent().unwrap()).unwrap();
573        fs::write(&path, "this_has_no_equals\ntheme=grape\njust_text\n").unwrap();
574        let state = load_state_from(&path);
575        assert_eq!(state.theme, Some("grape".into()));
576    }
577
578    #[test]
579    fn value_containing_equals_sign_is_preserved() {
580        // Paths on some systems may theoretically contain `=`; we split on
581        // the *first* `=` only, so the rest of the value is intact.
582        let (_dir, path) = tmp_state_path();
583        fs::create_dir_all(path.parent().unwrap()).unwrap();
584        // Manufacture a value with an embedded `=` via the theme field
585        // (unusual but the parser must handle it without panicking).
586        fs::write(&path, "theme=weird=name\n").unwrap();
587        let state = load_state_from(&path);
588        assert_eq!(state.theme, Some("weird=name".into()));
589    }
590
591    #[test]
592    fn surrounding_whitespace_in_values_is_trimmed() {
593        let (_dir, path) = tmp_state_path();
594        fs::create_dir_all(path.parent().unwrap()).unwrap();
595        fs::write(&path, "theme=  dracula  \nshow_hidden=  true  \n").unwrap();
596        let state = load_state_from(&path);
597        assert_eq!(state.theme, Some("dracula".into()));
598        assert_eq!(state.show_hidden, Some(true));
599    }
600
601    // ── Sort mode ─────────────────────────────────────────────────────────────
602
603    #[test]
604    fn all_sort_modes_round_trip() {
605        for mode in [SortMode::Name, SortMode::SizeDesc, SortMode::Extension] {
606            let (_dir, path) = tmp_state_path();
607            let state = AppState {
608                sort_mode: Some(mode),
609                ..Default::default()
610            };
611            save_state_to(&path, &state).unwrap();
612            let loaded = load_state_from(&path);
613            assert_eq!(
614                loaded.sort_mode,
615                Some(mode),
616                "round-trip failed for {mode:?}"
617            );
618        }
619    }
620
621    #[test]
622    fn unknown_sort_mode_value_yields_none() {
623        let (_dir, path) = tmp_state_path();
624        fs::create_dir_all(path.parent().unwrap()).unwrap();
625        fs::write(&path, "sort_mode=bogus_value\n").unwrap();
626        assert!(load_state_from(&path).sort_mode.is_none());
627    }
628
629    // ── Boolean fields ────────────────────────────────────────────────────────
630
631    #[test]
632    fn show_hidden_true_round_trips() {
633        let (_dir, path) = tmp_state_path();
634        let state = AppState {
635            show_hidden: Some(true),
636            ..Default::default()
637        };
638        save_state_to(&path, &state).unwrap();
639        assert_eq!(load_state_from(&path).show_hidden, Some(true));
640    }
641
642    #[test]
643    fn show_hidden_false_round_trips() {
644        let (_dir, path) = tmp_state_path();
645        let state = AppState {
646            show_hidden: Some(false),
647            ..Default::default()
648        };
649        save_state_to(&path, &state).unwrap();
650        assert_eq!(load_state_from(&path).show_hidden, Some(false));
651    }
652
653    #[test]
654    fn single_pane_true_round_trips() {
655        let (_dir, path) = tmp_state_path();
656        let state = AppState {
657            single_pane: Some(true),
658            ..Default::default()
659        };
660        save_state_to(&path, &state).unwrap();
661        assert_eq!(load_state_from(&path).single_pane, Some(true));
662    }
663
664    #[test]
665    fn single_pane_false_round_trips() {
666        let (_dir, path) = tmp_state_path();
667        let state = AppState {
668            single_pane: Some(false),
669            ..Default::default()
670        };
671        save_state_to(&path, &state).unwrap();
672        assert_eq!(load_state_from(&path).single_pane, Some(false));
673    }
674
675    #[test]
676    fn invalid_bool_value_yields_none() {
677        let (_dir, path) = tmp_state_path();
678        fs::create_dir_all(path.parent().unwrap()).unwrap();
679        fs::write(&path, "show_hidden=yes\nsingle_pane=1\n").unwrap();
680        let state = load_state_from(&path);
681        assert!(state.show_hidden.is_none(), "\"yes\" is not a valid bool");
682        assert!(state.single_pane.is_none(), "\"1\" is not a valid bool");
683    }
684
685    // ── last_dir ──────────────────────────────────────────────────────────────
686
687    #[test]
688    fn last_dir_round_trips_for_existing_directory() {
689        let (_dir, path) = tmp_state_path();
690        let existing = std::env::temp_dir(); // guaranteed to exist
691        let state = AppState {
692            last_dir: Some(existing.clone()),
693            ..Default::default()
694        };
695        save_state_to(&path, &state).unwrap();
696        assert_eq!(load_state_from(&path).last_dir, Some(existing));
697    }
698
699    #[test]
700    fn last_dir_for_nonexistent_path_loads_as_none() {
701        let (_dir, path) = tmp_state_path();
702        fs::create_dir_all(path.parent().unwrap()).unwrap();
703        // Write a path that is extremely unlikely to exist.
704        fs::write(&path, "last_dir=/this/path/does/not/exist/tfe_test_xyz\n").unwrap();
705        assert!(
706            load_state_from(&path).last_dir.is_none(),
707            "stale last_dir should be silently discarded"
708        );
709    }
710
711    #[test]
712    fn last_dir_empty_value_loads_as_none() {
713        let (_dir, path) = tmp_state_path();
714        fs::create_dir_all(path.parent().unwrap()).unwrap();
715        fs::write(&path, "last_dir=\n").unwrap();
716        assert!(load_state_from(&path).last_dir.is_none());
717    }
718
719    // ── Theme field ───────────────────────────────────────────────────────────
720
721    #[test]
722    fn theme_names_with_spaces_and_hyphens_round_trip() {
723        let names = [
724            "default",
725            "grape",
726            "catppuccin-mocha",
727            "tokyo night",
728            "Nord",
729        ];
730        for name in names {
731            let (_dir, path) = tmp_state_path();
732            let state = AppState {
733                theme: Some(name.into()),
734                ..Default::default()
735            };
736            save_state_to(&path, &state).unwrap();
737            assert_eq!(
738                load_state_from(&path).theme,
739                Some(name.to_string()),
740                "round-trip failed for theme {name:?}"
741            );
742        }
743    }
744
745    #[test]
746    fn empty_theme_value_loads_as_none() {
747        let (_dir, path) = tmp_state_path();
748        fs::create_dir_all(path.parent().unwrap()).unwrap();
749        fs::write(&path, "theme=\n").unwrap();
750        assert!(load_state_from(&path).theme.is_none());
751    }
752
753    // ── Legacy backward compatibility ─────────────────────────────────────────
754
755    #[test]
756    fn legacy_theme_file_content_is_readable() {
757        let (_dir, path) = tmp_theme_path();
758        fs::create_dir_all(path.parent().unwrap()).unwrap();
759        // Simulate what the old `tfe` wrote: just a theme name, possibly with
760        // a trailing newline added by the OS or a text editor.
761        fs::write(&path, "  nord\n").unwrap();
762        let raw = fs::read_to_string(&path).unwrap();
763        let trimmed = raw.trim().to_string();
764        assert_eq!(trimmed, "nord");
765    }
766
767    #[test]
768    fn legacy_theme_file_with_trailing_whitespace_is_trimmed() {
769        let (_dir, path) = tmp_theme_path();
770        fs::create_dir_all(path.parent().unwrap()).unwrap();
771        fs::write(&path, "\t dracula \n\n").unwrap();
772        let raw = fs::read_to_string(&path).unwrap();
773        let trimmed = raw.trim().to_string();
774        assert_eq!(trimmed, "dracula");
775        assert!(!trimmed.is_empty());
776    }
777
778    // ── resolve_theme_idx ─────────────────────────────────────────────────────
779
780    #[test]
781    fn resolve_theme_idx_finds_default_theme_at_zero() {
782        let themes = Theme::all_presets();
783        assert_eq!(resolve_theme_idx("default", &themes), 0);
784    }
785
786    #[test]
787    fn resolve_theme_idx_finds_named_theme() {
788        let themes = Theme::all_presets();
789        let idx = resolve_theme_idx("grape", &themes);
790        assert_ne!(idx, 0, "grape must not collide with the default index");
791        assert_eq!(themes[idx].0.to_lowercase(), "grape");
792    }
793
794    #[test]
795    fn resolve_theme_idx_is_case_insensitive() {
796        let themes = Theme::all_presets();
797        let lower = resolve_theme_idx("grape", &themes);
798        let upper = resolve_theme_idx("GRAPE", &themes);
799        let mixed = resolve_theme_idx("Grape", &themes);
800        assert_eq!(lower, upper, "lower vs upper");
801        assert_eq!(lower, mixed, "lower vs mixed");
802    }
803
804    #[test]
805    fn resolve_theme_idx_normalises_hyphens_to_spaces() {
806        let themes = Theme::all_presets();
807        let spaced = resolve_theme_idx("catppuccin mocha", &themes);
808        let hyphen = resolve_theme_idx("catppuccin-mocha", &themes);
809        assert_eq!(spaced, hyphen);
810    }
811
812    #[test]
813    fn resolve_theme_idx_unknown_name_returns_zero() {
814        let themes = Theme::all_presets();
815        assert_eq!(resolve_theme_idx("this-theme-does-not-exist", &themes), 0);
816    }
817
818    #[test]
819    fn resolve_theme_idx_persisted_name_survives_round_trip() {
820        let themes = Theme::all_presets();
821        let (_dir, path) = tmp_state_path();
822
823        let original_idx = resolve_theme_idx("nord", &themes);
824        let original_name = themes[original_idx].0;
825
826        let state = AppState {
827            theme: Some(original_name.into()),
828            ..Default::default()
829        };
830        save_state_to(&path, &state).unwrap();
831
832        let loaded_name = load_state_from(&path).theme.unwrap();
833        let loaded_idx = resolve_theme_idx(&loaded_name, &themes);
834
835        assert_eq!(
836            original_idx, loaded_idx,
837            "theme index must survive a full save/load cycle"
838        );
839    }
840
841    #[test]
842    fn resolve_theme_idx_all_presets_are_found() {
843        let themes = Theme::all_presets();
844        // Every preset must resolve to itself (not fall back to 0) unless it
845        // is legitimately the first entry.
846        for (i, (name, _, _)) in themes.iter().enumerate() {
847            let resolved = resolve_theme_idx(name, &themes);
848            assert_eq!(
849                resolved, i,
850                "preset {name:?} resolved to wrong index {resolved} (expected {i})"
851            );
852        }
853    }
854
855    // ── Full end-to-end (all fields) ──────────────────────────────────────────
856
857    #[test]
858    fn all_fields_independent_round_trips() {
859        // Verify each field persists correctly when set alone, confirming no
860        // cross-field interference in the serialiser / parser.
861        let existing_dir = std::env::temp_dir();
862
863        let cases: Vec<AppState> = vec![
864            AppState {
865                theme: Some("dracula".into()),
866                ..Default::default()
867            },
868            AppState {
869                last_dir: Some(existing_dir.clone()),
870                ..Default::default()
871            },
872            AppState {
873                sort_mode: Some(SortMode::Extension),
874                ..Default::default()
875            },
876            AppState {
877                show_hidden: Some(true),
878                ..Default::default()
879            },
880            AppState {
881                single_pane: Some(true),
882                ..Default::default()
883            },
884        ];
885
886        for case in cases {
887            let (_dir, path) = tmp_state_path();
888            save_state_to(&path, &case).unwrap();
889            let loaded = load_state_from(&path);
890            assert_eq!(loaded, case, "round-trip failed for {case:?}");
891        }
892    }
893
894    // ── single-pane: last_dir_right preservation ──────────────────────────────
895
896    /// Simulates the exact save logic in main.rs:
897    ///
898    ///   let last_dir_right = if app.single_pane {
899    ///       saved.last_dir_right.clone()   // ← preserve, don't clobber
900    ///   } else {
901    ///       Some(app.right.current_dir.clone())
902    ///   };
903    ///
904    /// When single-pane mode is active the right pane is hidden and its
905    /// current_dir mirrors the left pane's starting directory.  If we wrote
906    /// that mirrored value we'd clobber the real right-pane path, so we
907    /// preserve whatever was previously persisted.
908    #[test]
909    fn last_dir_right_is_preserved_when_single_pane_is_active() {
910        let (_dir, path) = tmp_state_path();
911        let left_dir = std::env::temp_dir();
912        let right_dir = {
913            // Use a sub-directory of temp so it's a different path that exists.
914            let p = std::env::temp_dir().join("tfe_test_right_pane_persist");
915            std::fs::create_dir_all(&p).unwrap();
916            p
917        };
918
919        // First session: dual-pane, user navigated each pane to a different dir.
920        let first_session = AppState {
921            last_dir: Some(left_dir.clone()),
922            last_dir_right: Some(right_dir.clone()),
923            single_pane: Some(false),
924            ..Default::default()
925        };
926        save_state_to(&path, &first_session).unwrap();
927
928        // Second session starts: load state, user switches to single-pane, exits.
929        let saved = load_state_from(&path);
930        assert_eq!(
931            saved.last_dir_right,
932            Some(right_dir.clone()),
933            "right pane dir should have survived the first save"
934        );
935
936        // Replicate the main.rs save logic when single_pane == true:
937        // the right pane's current_dir is the same as left (it was never navigated).
938        let mirrored_right = left_dir.clone(); // what app.right.current_dir would be
939        let last_dir_right = if true
940        /* single_pane active */
941        {
942            saved.last_dir_right.clone() // preserve
943        } else {
944            Some(mirrored_right)
945        };
946
947        let second_session = AppState {
948            last_dir: Some(left_dir.clone()),
949            last_dir_right,
950            single_pane: Some(true),
951            ..Default::default()
952        };
953        save_state_to(&path, &second_session).unwrap();
954
955        let restored = load_state_from(&path);
956        assert_eq!(
957            restored.last_dir_right,
958            Some(right_dir.clone()),
959            "last_dir_right must not be clobbered by the hidden right pane's mirrored path \
960             when single_pane was active on exit"
961        );
962        assert_ne!(
963            restored.last_dir_right, restored.last_dir,
964            "right and left pane dirs should remain independent after a single-pane session"
965        );
966    }
967
968    /// When the app is opened for the very first time (no prior state file),
969    /// last_dir_right is None and the right pane correctly mirrors the left.
970    #[test]
971    fn last_dir_right_is_none_on_fresh_install() {
972        let dir = tempfile::tempdir().unwrap();
973        let path = dir.path().join("nonexistent").join("state");
974        let state = load_state_from(&path);
975        assert!(
976            state.last_dir_right.is_none(),
977            "fresh install should have no persisted right-pane dir"
978        );
979    }
980
981    /// Dual-pane exit: last_dir_right IS updated (the normal case).
982    #[test]
983    fn last_dir_right_is_updated_when_dual_pane_is_active() {
984        let (_dir, path) = tmp_state_path();
985        let left_dir = std::env::temp_dir();
986        let right_dir = {
987            let p = std::env::temp_dir().join("tfe_test_right_dual");
988            std::fs::create_dir_all(&p).unwrap();
989            p
990        };
991
992        let state = AppState {
993            last_dir: Some(left_dir.clone()),
994            last_dir_right: Some(right_dir.clone()),
995            single_pane: Some(false),
996            ..Default::default()
997        };
998        save_state_to(&path, &state).unwrap();
999
1000        let loaded = load_state_from(&path);
1001        assert_eq!(
1002            loaded.last_dir_right,
1003            Some(right_dir),
1004            "dual-pane exit should persist the right pane's actual directory"
1005        );
1006    }
1007}