use std::fs;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use tempfile::TempDir;
use ratatree::{FilePickerState, PickerMode, PickerResult};
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn key_char(c: char) -> Event {
key(KeyCode::Char(c))
}
fn setup_test_dir() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src").join("main.rs"), "fn main() {}").unwrap();
fs::write(tmp.path().join("src").join("lib.rs"), "").unwrap();
fs::create_dir(tmp.path().join("tests")).unwrap();
fs::write(tmp.path().join("tests").join("test.rs"), "").unwrap();
fs::write(tmp.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(tmp.path().join("README.md"), "# Hello").unwrap();
fs::write(tmp.path().join(".gitignore"), "/target").unwrap();
tmp
}
#[test]
fn navigate_and_select_single_file() {
let tmp = setup_test_dir();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
state.handle_event(key(KeyCode::Down));
state.handle_event(key(KeyCode::Down));
let entry = state.current_entry().expect("should have entry");
assert_eq!(entry.name, "Cargo.toml");
state.handle_event(key(KeyCode::Enter));
match state.result() {
PickerResult::Selected(paths) => {
assert_eq!(paths.len(), 1);
assert!(paths[0].ends_with("Cargo.toml"), "expected Cargo.toml, got {:?}", paths[0]);
}
other => panic!("expected Selected, got {:?}", other),
}
}
#[test]
fn multi_select_across_navigation() {
let tmp = setup_test_dir();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
state.handle_event(key(KeyCode::Down));
state.handle_event(key(KeyCode::Down));
assert_eq!(state.current_entry().unwrap().name, "Cargo.toml");
state.handle_event(key_char(' '));
assert_eq!(state.common.selected.len(), 1);
state.handle_event(key(KeyCode::Down));
assert_eq!(state.current_entry().unwrap().name, "README.md");
state.handle_event(key_char(' '));
assert_eq!(state.common.selected.len(), 2);
state.handle_event(key(KeyCode::Enter));
match state.result() {
PickerResult::Selected(paths) => {
assert_eq!(paths.len(), 2);
let names: Vec<&str> = paths
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()))
.collect();
assert!(names.contains(&"Cargo.toml"), "expected Cargo.toml in {:?}", names);
assert!(names.contains(&"README.md"), "expected README.md in {:?}", names);
}
other => panic!("expected Selected, got {:?}", other),
}
}
#[test]
fn directory_navigation() {
let tmp = setup_test_dir();
let original_dir = tmp.path().canonicalize().unwrap();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
let first = state.current_entry().expect("should have entry");
assert_eq!(first.name, "src");
state.handle_event(key(KeyCode::Enter));
let current = state.common.current_dir.clone();
assert!(
current.ends_with("src"),
"expected current_dir to end with 'src', got {:?}",
current
);
state.handle_event(key_char('h'));
let back = state.common.current_dir.canonicalize().unwrap_or_else(|_| state.common.current_dir.clone());
assert_eq!(back, original_dir, "expected to be back at original dir");
}
#[test]
fn view_toggle_preserves_directory() {
let tmp = setup_test_dir();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
let dir_before = state.common.current_dir.clone();
let count_before = state.visible_count();
state.handle_event(key(KeyCode::Tab));
assert_eq!(state.common.current_dir, dir_before);
assert_eq!(state.visible_count(), count_before);
state.handle_event(key(KeyCode::Tab));
assert_eq!(state.common.current_dir, dir_before);
assert_eq!(state.visible_count(), count_before);
}
#[test]
fn hidden_files_toggle() {
let tmp = setup_test_dir();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
let count_before = state.visible_count();
assert!(
!state.visible_entries().iter().any(|e| e.name == ".gitignore"),
"should not see .gitignore before toggle"
);
state.handle_event(key_char('.'));
let count_after = state.visible_count();
assert!(
count_after > count_before,
"entry count should increase when showing hidden files ({} -> {})",
count_before,
count_after
);
assert!(
state.visible_entries().iter().any(|e| e.name == ".gitignore"),
"should see .gitignore after toggle"
);
}
#[test]
fn search_and_confirm() {
let tmp = setup_test_dir();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
state.handle_event(key_char('/'));
assert_eq!(state.common.input_mode, ratatree::InputMode::Search);
state.handle_event(key_char('C'));
state.handle_event(key_char('a'));
state.handle_event(key_char('r'));
assert_eq!(
state.visible_count(),
1,
"expected 1 match for 'Car', got {}",
state.visible_count()
);
let matched = state.current_entry().expect("should have match");
assert_eq!(matched.name, "Cargo.toml");
state.handle_event(key(KeyCode::Enter));
assert_eq!(state.common.input_mode, ratatree::InputMode::Normal);
assert_eq!(state.visible_count(), 1, "filter should be preserved after Enter");
state.handle_event(key(KeyCode::Enter));
match state.result() {
PickerResult::Selected(paths) => {
assert_eq!(paths.len(), 1);
assert!(
paths[0].ends_with("Cargo.toml"),
"expected Cargo.toml, got {:?}",
paths[0]
);
}
other => panic!("expected Selected, got {:?}", other),
}
}
#[test]
fn files_only_mode_blocks_dir_selection() {
let tmp = setup_test_dir();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.mode(PickerMode::FilesOnly)
.build();
let first = state.current_entry().expect("should have entry");
assert_eq!(first.kind, ratatree::EntryKind::Directory, "first entry should be a dir");
state.handle_event(key_char(' '));
assert_eq!(
state.common.selected.len(),
0,
"FilesOnly mode should block directory selection"
);
}
#[test]
fn cancel_returns_cancelled() {
let tmp = setup_test_dir();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
assert_eq!(state.result(), PickerResult::Pending);
state.handle_event(key(KeyCode::Esc));
assert_eq!(state.result(), PickerResult::Cancelled);
}
#[cfg(unix)]
#[test]
fn symlink_cycle_detection() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let canonical_tmp = tmp.path().canonicalize().unwrap();
let self_link = tmp.path().join("self_link");
symlink(tmp.path(), &self_link).unwrap();
let mut state = FilePickerState::builder()
.start_dir(tmp.path())
.build();
let self_link_entry = state
.visible_entries()
.into_iter()
.find(|e| e.name == "self_link")
.expect("self_link should be visible");
assert_eq!(self_link_entry.kind, ratatree::EntryKind::Symlink);
let link_idx = state
.common
.entries
.iter()
.position(|e| e.name == "self_link")
.expect("should find self_link in entries");
*state.view.cursor_mut() = link_idx;
state.handle_event(key(KeyCode::Right));
let actual_canonical = state.common.current_dir.canonicalize().unwrap_or_else(|_| state.common.current_dir.clone());
assert_eq!(
actual_canonical,
canonical_tmp,
"after entering self_link, resolved current_dir should equal canonical_tmp"
);
assert!(state.common.error_message.is_none(), "no error on first entry");
let link_idx_inside = state
.common
.entries
.iter()
.position(|e| e.name == "self_link")
.expect("self_link should still appear inside");
*state.view.cursor_mut() = link_idx_inside;
let dir_before_second_enter = state.common.current_dir.clone();
state.handle_event(key(KeyCode::Right));
assert!(
state.common.error_message.is_some(),
"expected error_message to be set for circular symlink"
);
assert_eq!(
state.common.current_dir,
dir_before_second_enter,
"current_dir should remain unchanged after circular symlink block"
);
}