use std::env; use super::*; /// A fresh folder under the temp dir. fn folder(name: &str) -> PathBuf { let folder = env::temp_dir().join(format!("elyterminal-{name}-{}", std::process::id())); if folder.exists() { fs::remove_dir_all(&folder).expect("a stale test folder goes"); } fs::create_dir_all(&folder).expect("a test folder"); folder } #[test] fn folders_come_first_then_files_by_name_ignoring_case() { let root = folder("files-list"); for name in ["Zeta", "alpha"] { fs::create_dir(root.join(name)).expect("a folder"); } for name in ["c.txt", "B.txt", "a.txt"] { fs::write(root.join(name), "").expect("a file"); } #[cfg(unix)] { use std::os::unix::fs::symlink; symlink(root.join("Zeta"), root.join("link")).expect("a link to a folder"); symlink(root.join("nowhere"), root.join("broken")).expect("a broken link"); } let expected: &[(&str, bool)] = if cfg!(unix) { &[ ("alpha", true), ("link", true), ("Zeta", true), ("a.txt", false), ("B.txt", false), ("broken", false), ("c.txt", false), ] } else { &[ ("alpha", true), ("Zeta", true), ("a.txt", false), ("B.txt", false), ("c.txt", false), ] }; let listed: Vec<_> = list(&root).expect("a listing"); let listed: Vec<_> = listed .iter() .map(|entry| (entry.name.to_str().expect("a UTF-8 name"), entry.folder)) .collect(); assert_eq!(listed, expected); assert!(list(&root.join("missing")).is_err()); } #[test] fn a_mark_dots_each_folder_above_it_up_to_the_root() { let root = Path::new("/r"); let changes = vec![ ( root.join("src").join("ui").join("a.rs"), GitStatus::Modified, ), (root.join("gone.txt"), GitStatus::Deleted), ]; let (marks, dirty) = marks(root, changes); assert_eq!(marks.len(), 2); let expected = HashSet::from([ root.to_path_buf(), root.join("src"), root.join("src").join("ui"), ]); assert_eq!(dirty, expected); } #[test] fn the_tree_wears_marks_and_an_untracked_folder_marks_what_it_holds() { let root = PathBuf::from("/r"); let (notes, src, locked) = (root.join("notes"), root.join("src"), root.join("locked")); let entry = |name: &str, folder| Entry { name: name.into(), folder, }; let mut files = Files::new(root.clone()); let top = vec![ entry("notes", true), entry("src", true), entry("locked", true), entry("a.txt", false), ]; files.listings.insert(root.clone(), Ok(top)); let inside = vec![entry("deep", true), entry("todo.txt", false)]; files.listings.insert(notes.clone(), Ok(inside)); files .listings .insert(src.clone(), Ok(vec![entry("main.rs", false)])); files.listings.insert(locked.clone(), Err("denied".into())); let changes = vec![ (notes.clone(), GitStatus::Untracked), (src.join("main.rs"), GitStatus::Modified), ]; (files.marks, files.dirty) = marks(&root, changes); let node = |path: &Path, icon| { let label = path .file_name() .expect("a name") .to_string_lossy() .to_string(); TreeNode::new(key(path), label).icon(icon) }; let untracked = |node: TreeNode| node.note("U").tone(Tone::Success); let dotted = |node: TreeNode| node.note("•").tone(Tone::Warning); let expected = dotted( node(&root, IconName::Folder).children([ untracked(node(¬es, IconName::Folder).children([ untracked(node(¬es.join("deep"), IconName::Folder).pending()), untracked(node(¬es.join("todo.txt"), IconName::File)), ])), dotted( node(&src, IconName::Folder).children([node(&src.join("main.rs"), IconName::File) .note("M") .tone(Tone::Warning)]), ), node(&locked, IconName::Folder) .children([]) .note("unreadable") .tone(Tone::Danger), node(&root.join("a.txt"), IconName::File), ]), ); assert_eq!(files.folder(&root, "r".into(), false), expected); } #[test] fn names_with_newlines_label_on_one_line_and_keep_their_paths() { let root = PathBuf::from("/r"); let (folder, file) = (root.join("line\nbreak"), root.join("report\n2026.txt")); let mut files = Files::new(root.clone()); let entries = vec![ Entry { name: "line\nbreak".into(), folder: true, }, Entry { name: "report\n2026.txt".into(), folder: false, }, ]; files.listings.insert(root.clone(), Ok(entries)); let expected = TreeNode::new(key(&root), "r") .icon(IconName::Folder) .children([ TreeNode::new(key(&folder), "line␊break") .icon(IconName::Folder) .pending(), TreeNode::new(key(&file), "report␊2026.txt").icon(IconName::File), ]); assert_eq!(files.folder(&root, "r".into(), false), expected); } #[cfg(unix)] #[test] fn names_that_show_alike_keep_their_own_keys_and_paths() { use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; let root = PathBuf::from("/r"); let raw = OsStr::from_bytes(b"bad\xff.txt"); let (text, bytes) = (root.join("bad\u{FFFD}.txt"), root.join(raw)); let mut files = Files::new(root.clone()); let entry = |name: &OsStr| Entry { name: name.into(), folder: false, }; let entries = vec![entry(OsStr::new("bad\u{FFFD}.txt")), entry(raw)]; files.listings.insert(root.clone(), Ok(entries)); assert_ne!(key(&text), key(&bytes), "two paths, two keys"); let expected = TreeNode::new(key(&root), "r") .icon(IconName::Folder) .children([ TreeNode::new(key(&text), "bad\u{FFFD}.txt").icon(IconName::File), TreeNode::new(key(&bytes), "bad\u{FFFD}.txt").icon(IconName::File), ]); assert_eq!(files.folder(&root, "r".into(), false), expected); for path in [&root, &text, &bytes] { let found = files.path(&key(path)); assert_eq!(found.as_ref(), Some(path), "{}", path.display()); } assert_eq!(files.path("/r/gone"), None); } /// Runs git in `root` as a fixed author, unsigned. #[cfg(target_os = "macos")] fn run(root: &Path, args: &[&str]) { let done = std::process::Command::new("git") .arg("-C") .arg(root) .args(["-c", "user.name=Ely", "-c", "user.email=ely@example.com"]) .args(["-c", "commit.gpgsign=false"]) .args(args) .output() .expect("git runs"); let error = String::from_utf8_lossy(&done.stderr); assert!(done.status.success(), "git {args:?}: {error}"); } #[cfg(target_os = "macos")] #[test] fn decomposed_names_on_disk_wear_the_composed_marks_git_writes() { let root = folder("files-nfd"); run(&root, &["init", "-q"]); run(&root, &["config", "core.precomposeunicode", "true"]); run(&root, &["config", "status.showUntrackedFiles", "normal"]); let (dossier, tracked, untracked) = ( "dossier-e\u{301}", "tracked-e\u{301}.txt", "new-e\u{301}.txt", ); fs::create_dir(root.join(dossier)).expect("a folder"); fs::write(root.join(dossier).join("inner.txt"), "first").expect("a file"); fs::write(root.join(tracked), "first").expect("a file"); run(&root, &["add", "."]); run(&root, &["commit", "-qm", "first"]); fs::write(root.join(dossier).join("inner.txt"), "second").expect("an edit"); fs::write(root.join(tracked), "second").expect("an edit"); fs::write(root.join(untracked), "new").expect("an untracked file"); let mut files = Files::new(root.clone()); files.listings.insert(root.clone(), list(&root)); let changes = git::changes(&root).expect("status").expect("a work tree"); (files.marks, files.dirty) = marks(&root, changes); let node = |name: &str, icon| TreeNode::new(key(&root.join(name)), name).icon(icon); let dotted = |node: TreeNode| node.note("•").tone(Tone::Warning); let expected = dotted( TreeNode::new(key(&root), "r") .icon(IconName::Folder) .children([ node(".git", IconName::Folder).pending(), dotted(node(dossier, IconName::Folder).pending()), node(untracked, IconName::File) .note("U") .tone(Tone::Success), node(tracked, IconName::File).note("M").tone(Tone::Warning), ]), ); assert_eq!(files.folder(&root, "r".into(), false), expected); }