1use std::{
27 fs, io,
28 path::{Path, PathBuf},
29};
30
31use crate::{SortMode, Theme};
32
33const 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#[derive(Debug, Default, Clone, PartialEq, Eq)]
64pub struct AppState {
65 pub theme: Option<String>,
67
68 pub last_dir: Option<PathBuf>,
73
74 pub last_dir_right: Option<PathBuf>,
79
80 pub sort_mode: Option<SortMode>,
82
83 pub show_hidden: Option<bool>,
85
86 pub single_pane: Option<bool>,
88
89 pub cd_on_exit: Option<bool>,
95
96 pub editor: Option<String>,
101}
102
103fn 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
115pub fn state_path() -> Option<PathBuf> {
117 config_dir().map(|d| d.join("state"))
118}
119
120pub(crate) fn legacy_theme_path() -> Option<PathBuf> {
125 config_dir().map(|d| d.join("theme"))
126}
127
128fn 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
139fn 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
152pub(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 if line.is_empty() || line.starts_with('#') {
174 continue;
175 }
176
177 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 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 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 }
219 }
220 }
221
222 state
223}
224
225pub(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
264pub 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 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
296pub fn save_state(state: &AppState) {
301 if let Some(path) = state_path() {
302 let _ = save_state_to(&path, state);
303 }
304}
305
306pub 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#[cfg(test)]
333mod tests {
334 #[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 #[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 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 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 #[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 #[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 let (_dir, path) = tmp_state_path();
583 fs::create_dir_all(path.parent().unwrap()).unwrap();
584 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 #[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 #[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 #[test]
688 fn last_dir_round_trips_for_existing_directory() {
689 let (_dir, path) = tmp_state_path();
690 let existing = std::env::temp_dir(); 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 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 #[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 #[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 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 #[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 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 #[test]
858 fn all_fields_independent_round_trips() {
859 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 #[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 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 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 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 let mirrored_right = left_dir.clone(); let last_dir_right = if true
940 {
942 saved.last_dir_right.clone() } 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 #[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 #[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}