use super::*;
use crate::gui::editor_widget::{EditorAction, EditorBuffer};
#[test]
fn test_compute_text_matches() {
struct Case {
text: &'static str,
query: &'static str,
sensitive: bool,
expected: &'static [(usize, usize)],
}
let cases: &[Case] = &[
Case {
text: "hello",
query: "",
sensitive: true,
expected: &[],
},
Case {
text: "hello world hello",
query: "hello",
sensitive: true,
expected: &[(0, 5), (12, 17)],
},
Case {
text: "hello world",
query: "xyz",
sensitive: true,
expected: &[],
},
Case {
text: "aaaaa",
query: "aa",
sensitive: true,
expected: &[(0, 2), (2, 4)],
},
Case {
text: "Hello World hello",
query: "hello",
sensitive: false,
expected: &[(0, 5), (12, 17)],
},
Case {
text: "Hello World",
query: "xyz",
sensitive: false,
expected: &[],
},
Case {
text: "hello",
query: "h",
sensitive: true,
expected: &[],
},
Case {
text: "ab",
query: "ab",
sensitive: true,
expected: &[(0, 2)],
},
Case {
text: "abab",
query: "ab",
sensitive: true,
expected: &[(0, 2), (2, 4)],
},
];
for case in cases {
let result = compute_text_matches(case.text, case.query, case.sensitive);
assert_eq!(
result.len(),
case.expected.len(),
"text={:?} query={:?} sensitive={}",
case.text,
case.query,
case.sensitive
);
for (i, &(start, end)) in case.expected.iter().enumerate() {
assert_eq!(
result[i],
start..end,
"match[{i}] text={:?} query={:?} sensitive={}",
case.text,
case.query,
case.sensitive
);
}
}
}
#[test]
fn test_validate_file_content() {
let big = || vec![b'a'; usize::try_from(MAX_FILE_SIZE).unwrap() + 1];
let big_bytes = big();
let mut big_with_null = big();
big_with_null.push(0);
let cases: &[(&[u8], Option<&str>)] = &[
(b"", None),
(b"hello world", None),
("Привет мир 👋".as_bytes(), None),
(&big_bytes, Some("File too large")),
(b"hello\0world", Some("Binary file detected")),
(&big_with_null, Some("File too large")),
];
for &(bytes, expected) in cases {
match expected {
Some(prefix) => {
let err = validate_file_content(bytes).unwrap_err();
assert!(err.starts_with(prefix), "unexpected error: {err}");
}
None => assert!(validate_file_content(bytes).is_ok()),
}
}
}
#[test]
fn test_byte_offset_to_line_byte_col_unicode() {
let text = "Привет **мир**";
let (line, byte_col, line_start) = byte_offset_to_line_byte_col(text, 13).unwrap();
assert_eq!(line, 0);
assert_eq!(byte_col, 13);
assert_eq!(line_start, 0);
let (_, byte_end_col, line_start) = byte_offset_to_line_byte_col(text, 21).unwrap();
assert_eq!(byte_end_col, 21 - line_start);
}
#[test]
fn test_build_tab_records_persists_dirty_content() {
let tabs = vec![Tab {
path: "/tmp/foo.md".to_string(),
file_name: "foo.md".to_string(),
is_dirty: true,
line_ending: LineEnding::Lf,
}];
let mut tab_contents = HashMap::new();
let buffer = EditorBuffer::with_text("unsaved edits", None);
tab_contents.insert(
"/tmp/foo.md".to_string(),
TabData {
content: buffer,
undo_stack: RefCell::new(UndoStack::new()),
find_replace_state: None,
saved_text_hash: 0,
},
);
let records = build_tab_records(&tabs, 0, &tab_contents);
assert_eq!(records.len(), 1);
assert!(records[0].is_dirty);
assert_eq!(records[0].dirty_content.as_deref(), Some("unsaved edits"));
}
#[test]
fn test_build_tab_records_clears_dirty_content_when_clean() {
let tabs = vec![Tab {
path: "/tmp/foo.md".to_string(),
file_name: "foo.md".to_string(),
is_dirty: false,
line_ending: LineEnding::Lf,
}];
let records = build_tab_records(&tabs, 0, &HashMap::new());
assert!(records[0].dirty_content.is_none());
}
#[test]
fn test_save_result_ignores_stale_save() {
let mut state = EditorState::new();
let path = "/tmp/stale.md".to_string();
state.tabs.push(Tab {
path: path.clone(),
file_name: "stale.md".to_string(),
is_dirty: true,
line_ending: LineEnding::Lf,
});
state.tab_contents.insert(
path.clone(),
TabData {
content: EditorBuffer::with_text("edited after save started", None),
undo_stack: RefCell::new(UndoStack::new()),
find_replace_state: None,
saved_text_hash: hash_text("on disk"),
},
);
let saved_hash = hash_text("saved snapshot");
let _ = state.save_result(&path, Ok(()), saved_hash);
assert!(
state.tabs[0].is_dirty,
"stale save must not clear dirty flag"
);
}
#[test]
fn test_byte_offset_to_line_col() {
struct Case {
text: &'static str,
byte_offset: usize,
expected: (usize, usize),
}
let cases: &[Case] = &[
Case {
text: "Привет мир",
byte_offset: 13, expected: (0, 7),
},
Case {
text: "hello\nworld",
byte_offset: 0,
expected: (0, 0),
},
Case {
text: "hello\nworld",
byte_offset: 6, expected: (1, 0),
},
Case {
text: "hello\nworld",
byte_offset: 8, expected: (1, 2),
},
Case {
text: "hello",
byte_offset: 100,
expected: (0, 5),
},
Case {
text: "",
byte_offset: 0,
expected: (0, 0),
},
Case {
text: "",
byte_offset: 100,
expected: (0, 0),
},
];
for case in cases {
let pos = byte_offset_to_line_col(case.text, case.byte_offset);
assert_eq!(
pos, case.expected,
"text={:?} offset={}",
case.text, case.byte_offset
);
}
}
fn make_editor_with_tree() -> EditorState {
let mut state = EditorState::new();
state.selected_workspace_path = Some("/tmp".to_string());
state.dir_entries.insert(
String::new(),
vec![
FsEntry {
name: "src".to_string(),
full_path: "src".to_string(),
is_dir: true,
error: None,
},
FsEntry {
name: "Cargo.toml".to_string(),
full_path: "Cargo.toml".to_string(),
is_dir: false,
error: None,
},
],
);
state.dir_entries.insert(
"src".to_string(),
vec![FsEntry {
name: "main.rs".to_string(),
full_path: "src/main.rs".to_string(),
is_dir: false,
error: None,
}],
);
state.rebuild_tree();
state
}
#[test]
fn test_rebuild_visible_tree_flattens_nodes() {
let state = make_editor_with_tree();
assert_eq!(state.file_tree.visible_tree_nodes.len(), 2);
assert_eq!(state.file_tree.visible_tree_nodes[0].0, "src");
assert!(state.file_tree.visible_tree_nodes[0].1); assert_eq!(state.file_tree.visible_tree_nodes[1].0, "Cargo.toml");
assert!(!state.file_tree.visible_tree_nodes[1].1); }
#[test]
fn test_rebuild_visible_tree_with_expanded_dir() {
let mut state = make_editor_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes =
build_hierarchical_tree(&state.dir_entries, &state.file_tree.expanded_dirs, "");
state.file_tree.rebuild_visible();
assert_eq!(state.file_tree.visible_tree_nodes.len(), 3);
assert_eq!(state.file_tree.visible_tree_nodes[0].0, "src");
assert_eq!(state.file_tree.visible_tree_nodes[1].0, "src/main.rs");
assert_eq!(state.file_tree.visible_tree_nodes[2].0, "Cargo.toml");
}
#[test]
fn test_tree_focus_toggled_sets_focus() {
let mut state = make_editor_with_tree();
assert!(!state.file_tree.tree_focused);
let _ = state.update(EditorMessage::TreeFocusToggled);
assert!(state.file_tree.tree_focused);
let _ = state.update(EditorMessage::TreeFocusToggled);
assert!(!state.file_tree.tree_focused);
}
#[test]
fn test_tree_focus_toggled_empty_tree_stays_off() {
let mut state = EditorState::new();
assert!(!state.file_tree.tree_focused);
let _ = state.update(EditorMessage::TreeFocusToggled);
assert!(!state.file_tree.tree_focused); }
#[test]
fn test_misc_focus_actions() {
struct Case {
name: &'static str,
msg: EditorMessage,
setup: fn(&mut EditorState),
check: fn(&EditorState, name: &str),
}
let cases: &[Case] = &[
Case {
name: "escape_clears_tree_focus",
msg: EditorMessage::Escape,
setup: |s| s.file_tree.tree_focused = true,
check: |s, name| assert!(!s.file_tree.tree_focused, "case: {name}"),
},
Case {
name: "toggle_dir_sets_tree_focus",
msg: EditorMessage::ToggleDir("src".to_string()),
setup: |s| {
s.selected_file = Some("Cargo.toml".to_string());
},
check: |s, name| {
assert!(s.file_tree.tree_focused, "case: {name}");
assert!(s.selected_file.is_none(), "case: {name}");
},
},
Case {
name: "select_file_keeps_tree_focus",
msg: EditorMessage::SelectFile("src/main.rs".to_string()),
setup: |s| s.file_tree.tree_focused = true,
check: |s, name| assert!(s.file_tree.tree_focused, "case: {name}"),
},
Case {
name: "editor_action_clears_tree_focus",
msg: EditorMessage::EditorAction(EditorAction::MoveTo { line: 0, col: 0 }),
setup: |s| {
s.file_tree.tree_focused = true;
s.pending_enter_dir = Some("src".to_string());
s.active_modal = Some(ModalKind::Rename(RenameTarget {
path: "src/main.rs".to_string(),
abs_path: String::new(),
is_dir: false,
ws_root: String::new(),
input_text: "main.rs".to_string(),
error: None,
}));
},
check: |s, name| {
assert!(!s.file_tree.tree_focused, "case: {name}");
assert_eq!(s.pending_enter_dir, None, "case: {name}");
assert!(s.active_modal.is_none(), "case: {name}");
},
},
];
for case in cases {
let mut state = make_editor_with_tree();
(case.setup)(&mut state);
let _ = state.update(case.msg.clone());
(case.check)(&state, case.name);
}
}
#[test]
fn test_tree_nav_enter() {
struct Case {
name: &'static str,
focused: bool,
start_idx: usize,
pre_select_file: bool,
expect_focused: bool,
expected_idx: Option<usize>,
check: Option<fn(&EditorState, name: &str)>,
}
let cases: &[Case] = &[
Case {
name: "on_file_dispatches_task",
focused: true,
start_idx: 1,
pre_select_file: false,
expect_focused: true,
expected_idx: None,
check: None,
},
Case {
name: "not_focused_ignored",
focused: false,
start_idx: 1,
pre_select_file: false,
expect_focused: false,
expected_idx: Some(1),
check: None,
},
Case {
name: "on_dir_expands_and_advances",
focused: true,
start_idx: 0,
pre_select_file: true,
expect_focused: true,
expected_idx: Some(1),
check: Some(|s, name| {
assert!(s.file_tree.expanded_dirs.contains("src"), "case: {name}");
assert!(s.selected_file.is_none(), "case: {name}");
assert_eq!(s.file_tree.visible_tree_nodes[1].0, "src/main.rs");
}),
},
];
for case in cases {
let mut state = make_editor_with_tree();
state.file_tree.tree_focused = case.focused;
state.file_tree.tree_focus_index = case.start_idx;
if case.pre_select_file {
state.selected_file = Some("Cargo.toml".to_string());
}
let _ = state.update(EditorMessage::TreeNavEnter);
assert_eq!(
state.file_tree.tree_focused, case.expect_focused,
"case: {}",
case.name
);
if let Some(idx) = case.expected_idx {
assert_eq!(state.file_tree.tree_focus_index, idx, "case: {}", case.name);
}
if let Some(check) = case.check {
check(&state, case.name);
}
}
}
#[test]
fn test_visible_tree_clamps_focus_on_rebuild() {
let mut state = make_editor_with_tree();
state.file_tree.tree_focus_index = 999; state.file_tree.rebuild_visible();
assert_eq!(
state.file_tree.tree_focus_index,
state.file_tree.visible_tree_nodes.len() - 1
);
}
#[test]
fn test_async_enter_dir_sets_pending_then_advances() {
let mut state = EditorState::new();
state.selected_workspace_path = Some("/tmp".to_string());
state.dir_entries.insert(
String::new(),
vec![FsEntry {
name: "src".to_string(),
full_path: "src".to_string(),
is_dir: true,
error: None,
}],
);
state.rebuild_tree();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
let _task = state.update(EditorMessage::TreeNavEnter);
assert_eq!(state.pending_enter_dir.as_deref(), Some("src"));
assert!(state.file_tree.expanded_dirs.contains("src"));
assert_eq!(state.file_tree.tree_focus_index, 0);
let entries = vec![FsEntry {
name: "main.rs".to_string(),
full_path: "src/main.rs".to_string(),
is_dir: false,
error: None,
}];
let dir_gen = state.generation;
let _task = state.update(EditorMessage::DirExpanded {
dir_path: "src".to_string(),
r#gen: dir_gen,
entries: Ok(entries),
quiet: false,
});
assert_eq!(state.pending_enter_dir, None);
assert_eq!(state.file_tree.tree_focus_index, 1); }
#[test]
fn test_toggle_dir_async_load_and_complete() {
let mut state = EditorState::new();
state.selected_workspace_path = Some("/tmp".to_string());
state.dir_entries.insert(
String::new(),
vec![FsEntry {
name: "src".to_string(),
full_path: "src".to_string(),
is_dir: true,
error: None,
}],
);
state.rebuild_tree();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
let _task = state.update(EditorMessage::ToggleDir("src".to_string()));
assert!(state.loading_dirs.contains("src"));
assert!(state.dir_generations.contains_key("src"));
assert_eq!(state.pending_enter_dir, None);
assert!(state.file_tree.tree_focused);
assert_eq!(state.file_tree.tree_focus_index, 0);
let dir_gen = *state.dir_generations.get("src").unwrap();
let entries = vec![FsEntry {
name: "main.rs".to_string(),
full_path: "src/main.rs".to_string(),
is_dir: false,
error: None,
}];
let _task = state.update(EditorMessage::DirExpanded {
dir_path: "src".to_string(),
r#gen: dir_gen,
entries: Ok(entries),
quiet: false,
});
assert!(state.dir_entries.contains_key("src"));
assert_eq!(state.dir_entries["src"].len(), 1);
assert!(!state.loading_dirs.contains("src"));
assert_eq!(state.pending_enter_dir, None);
assert!(state.file_tree.visible_tree_nodes.len() >= 2);
assert_eq!(state.file_tree.visible_tree_nodes[0].0, "src");
assert_eq!(state.file_tree.visible_tree_nodes[1].0, "src/main.rs");
}
#[test]
fn test_toggle_dir_no_workspace_returns_none() {
let mut state = EditorState::new();
assert!(!state.file_tree.expanded_dirs.contains("src"));
let _task = state.update(EditorMessage::ToggleDir("src".to_string()));
assert!(state.file_tree.expanded_dirs.contains("src"));
assert_eq!(state.generation, 0);
assert!(state.loading_dirs.is_empty());
assert!(state.dir_generations.is_empty());
}
#[expect(clippy::too_many_lines)]
#[test]
fn test_parse_git_status_porcelain() {
struct Case {
name: &'static str,
input: &'static str,
expected: &'static [(&'static str, Option<GitFileStatus>)],
}
let cases: &[Case] = &[
Case {
name: "unstaged modified file",
input: " M src/main.rs\n",
expected: &[("src/main.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "staged added file",
input: "A new_file.rs\n",
expected: &[("new_file.rs", Some(GitFileStatus::Added))],
},
Case {
name: "untracked file",
input: "?? new_file.rs\n",
expected: &[("new_file.rs", Some(GitFileStatus::Added))],
},
Case {
name: "staged and unstaged modified (MM)",
input: "MM both.rs\n",
expected: &[("both.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "staged added + unstaged modified (AM)",
input: "AM partial.rs\n",
expected: &[("partial.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "rename (old -> new)",
input: "R old.rs -> new.rs\n",
expected: &[("new.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "rename with arrow in old path",
input: "R \"old -> name.rs\" -> \"new -> name.rs\"\n",
expected: &[("new -> name.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "untracked directory (trailing slash stripped)",
input: "?? new_dir/\n",
expected: &[("new_dir", Some(GitFileStatus::Added))],
},
Case {
name: "quoted path with spaces",
input: " M \"path with spaces.rs\"\n",
expected: &[("path with spaces.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "deleted file (unstaged) skipped",
input: " D gone.rs\n",
expected: &[],
},
Case {
name: "deleted file (staged) skipped",
input: "D gone.rs\n",
expected: &[],
},
Case {
name: "clean (unrecognized status) not present",
input: " clean.rs\n",
expected: &[("clean.rs", None)],
},
Case {
name: "multiple entries same file — modified wins over added",
input: "A dup.rs\n M dup.rs\n",
expected: &[("dup.rs", Some(GitFileStatus::Modified))],
},
Case {
name: "multiple entries same file — added sticks",
input: "?? dup.rs\nA dup.rs\n",
expected: &[("dup.rs", Some(GitFileStatus::Added))],
},
Case {
name: "empty output",
input: "",
expected: &[],
},
Case {
name: "mixed statuses",
input: concat!(
" M src/main.rs\n",
"?? new_file.rs\n",
"A staged.rs\n",
" D deleted.rs\n",
),
expected: &[
("src/main.rs", Some(GitFileStatus::Modified)),
("new_file.rs", Some(GitFileStatus::Added)),
("staged.rs", Some(GitFileStatus::Added)),
("deleted.rs", None),
],
},
];
for case in cases {
let map = parse_git_status_porcelain(case.input);
if case.expected.is_empty() {
assert!(
map.is_empty(),
"case '{}' (input={:?}): expected empty map, got {:#?}",
case.name,
case.input,
map
);
} else {
let expected_count = case.expected.iter().filter(|(_, s)| s.is_some()).count();
assert_eq!(
map.len(),
expected_count,
"case '{}' (input={:?}): map has unexpected entries",
case.name,
case.input,
);
for &(path, expected_status) in case.expected {
match expected_status {
Some(status) => {
assert_eq!(
map.get(path),
Some(&status),
"case '{}' (input={:?}): path={:?}",
case.name,
case.input,
path
);
}
None => {
assert!(
!map.contains_key(path),
"case '{}' (input={:?}): path={:?} should be absent, got {:?}",
case.name,
case.input,
path,
map.get(path)
);
}
}
}
}
}
}
#[test]
fn test_is_find_bar_open() {
let state =
make_editor_with_find_state("fn hello() {}", "hello", std::iter::once(4..9).collect(), 0);
assert!(state.is_find_bar_open());
let state = make_editor_with_single_tab("fn hello() {}");
assert!(!state.is_find_bar_open());
let state = EditorState::new();
assert!(!state.is_find_bar_open());
}
#[expect(clippy::too_many_lines)]
#[test]
fn test_find_replace_auto_advance() {
struct Case {
name: &'static str,
text: &'static str,
query: &'static str,
replace: &'static str,
initial_matches: &'static [(usize, usize)],
expected_text: &'static str,
expected_matches: &'static [(usize, usize)],
expected_cursor_line: usize,
expected_cursor_col: usize,
expected_current_match_idx: usize,
}
let cases: &[Case] = &[
Case {
name: "same_length",
text: "ab cd ab",
query: "ab",
replace: "xy",
initial_matches: &[(0, 2), (6, 8)],
expected_text: "xy cd ab",
expected_matches: &[(6, 8)],
expected_cursor_line: 0,
expected_cursor_col: 6,
expected_current_match_idx: 0,
},
Case {
name: "shorter_replacement",
text: "aaa bbb aaa",
query: "aaa",
replace: "a",
initial_matches: &[(0, 3), (8, 11)],
expected_text: "a bbb aaa",
expected_matches: &[(6, 9)],
expected_cursor_line: 0,
expected_cursor_col: 6,
expected_current_match_idx: 0,
},
Case {
name: "adjacent_matches",
text: "aaaa",
query: "aa",
replace: "x",
initial_matches: &[(0, 2), (2, 4)],
expected_text: "xaa",
expected_matches: &[(1, 3)],
expected_cursor_line: 0,
expected_cursor_col: 1,
expected_current_match_idx: 0,
},
Case {
name: "longer_replacement",
text: "ab",
query: "ab",
replace: "abc",
initial_matches: &[(0, 2)],
expected_text: "abc",
expected_matches: &[(0, 2)],
expected_cursor_line: 0,
expected_cursor_col: 0,
expected_current_match_idx: 0,
},
Case {
name: "no_more_matches",
text: "ab",
query: "ab",
replace: "xy",
initial_matches: &[(0, 2)],
expected_text: "xy",
expected_matches: &[],
expected_cursor_line: 0,
expected_cursor_col: 2,
expected_current_match_idx: 0,
},
];
let path = "/test.rs".to_string();
for c in cases {
let mut state = make_editor_with_single_tab(c.text);
if let Some(tab) = state.tab_contents.get_mut(&path) {
tab.find_replace_state = Some(FindReplaceState {
query: c.query.to_string(),
replace: c.replace.to_string(),
matches: c.initial_matches.iter().map(|&(s, e)| s..e).collect(),
current_match_idx: 0,
case_sensitive: true,
});
}
let _ = state.update(EditorMessage::FindReplace);
let tab = state.tab_contents.get(&path).unwrap();
let frs = tab.find_replace_state.as_ref().unwrap();
assert_eq!(tab.content.text(), c.expected_text, "{}: text", c.name);
assert_eq!(
frs.matches.len(),
c.expected_matches.len(),
"{}: match count",
c.name,
);
for (i, &(s, e)) in c.expected_matches.iter().enumerate() {
assert_eq!(frs.matches[i], s..e, "{}: match {i} range", c.name);
}
let cursor = tab.content.cursor();
assert_eq!(
cursor.line, c.expected_cursor_line,
"{}: cursor line",
c.name
);
assert_eq!(
cursor.column, c.expected_cursor_col,
"{}: cursor col",
c.name
);
assert_eq!(
frs.current_match_idx, c.expected_current_match_idx,
"{}: current_match_idx",
c.name,
);
}
}
fn make_editor_with_find_state(
text: &str,
query: &str,
matches: Vec<Range<usize>>,
current_match_idx: usize,
) -> EditorState {
let mut state = EditorState::new();
state.tabs.push(Tab {
path: "/test.rs".to_string(),
file_name: "test.rs".to_string(),
is_dirty: false,
line_ending: LineEnding::Lf,
});
state.active_tab_index = 0;
state.tab_contents.insert(
"/test.rs".to_string(),
TabData {
content: EditorBuffer::with_text(text, None),
undo_stack: RefCell::new(UndoStack::new()),
find_replace_state: Some(FindReplaceState {
query: query.to_string(),
replace: String::new(),
matches,
current_match_idx,
case_sensitive: true,
}),
saved_text_hash: 0,
},
);
state
}
#[test]
fn test_navigate_find_match_wraps() {
for direction in [FindDirection::Next, FindDirection::Prev] {
let mut state = make_editor_with_find_state("a b c", " ", vec![1..2, 3..4], 0);
for want in [1, 0] {
let _ = state.navigate_find_match(direction);
let s = state.tab_contents.get("/test.rs").unwrap();
let s = s.find_replace_state.as_ref().unwrap();
assert_eq!(s.current_match_idx, want, "{direction:?}");
}
}
}
#[test]
fn test_navigate_find_match_no_matches() {
let mut state = make_editor_with_find_state("no matches", "zzz", vec![], 0);
let _ = state.navigate_find_match(FindDirection::Next);
let _ = state.navigate_find_match(FindDirection::Prev);
let s = state.tab_contents.get("/test.rs").unwrap();
let s = s.find_replace_state.as_ref().unwrap();
assert_eq!(s.current_match_idx, 0);
}
#[test]
fn test_navigate_find_match_only_affects_find_tab() {
let mut state = make_editor_with_single_tab("hello");
let _ = state.navigate_find_match(FindDirection::Next);
let _ = state.navigate_find_match(FindDirection::Prev);
}
#[expect(clippy::too_many_lines)]
#[test]
fn test_tree_nav_left_right() {
struct Case {
name: &'static str,
msg: EditorMessage,
start_idx: usize,
pre_expand_src: bool,
pre_select_file: bool,
expected_idx: usize,
check: Option<fn(&EditorState, name: &str)>,
}
let cases: &[Case] = &[
Case {
name: "left_on_expanded_dir_collapses",
msg: EditorMessage::TreeNavLeft,
start_idx: 0,
pre_expand_src: true,
pre_select_file: false,
expected_idx: 0,
check: Some(|s, name| {
assert!(!s.file_tree.expanded_dirs.contains("src"), "case: {name}");
}),
},
Case {
name: "left_on_file_navigates_to_parent",
msg: EditorMessage::TreeNavLeft,
start_idx: 1,
pre_expand_src: true,
pre_select_file: false,
expected_idx: 0,
check: Some(|s, name| {
assert_eq!(s.file_tree.visible_tree_nodes[0].0, "src", "case: {name}");
}),
},
Case {
name: "left_on_root_collapsed_dir_noop",
msg: EditorMessage::TreeNavLeft,
start_idx: 0,
pre_expand_src: false,
pre_select_file: false,
expected_idx: 0,
check: None,
},
Case {
name: "left_on_root_file_noop",
msg: EditorMessage::TreeNavLeft,
start_idx: 1,
pre_expand_src: false,
pre_select_file: false,
expected_idx: 1,
check: None,
},
Case {
name: "right_on_collapsed_dir_expands_and_advances",
msg: EditorMessage::TreeNavRight,
start_idx: 0,
pre_expand_src: false,
pre_select_file: true,
expected_idx: 1,
check: Some(|s, name| {
assert!(s.file_tree.expanded_dirs.contains("src"), "case: {name}");
assert!(s.selected_file.is_none(), "case: {name}");
assert_eq!(s.file_tree.visible_tree_nodes[1].0, "src/main.rs");
}),
},
Case {
name: "right_on_expanded_dir_moves_to_first_child",
msg: EditorMessage::TreeNavRight,
start_idx: 0,
pre_expand_src: true,
pre_select_file: false,
expected_idx: 1,
check: Some(|s, name| {
assert_eq!(
s.file_tree.visible_tree_nodes[1].0, "src/main.rs",
"case: {name}"
);
}),
},
Case {
name: "right_on_file_noop",
msg: EditorMessage::TreeNavRight,
start_idx: 1,
pre_expand_src: false,
pre_select_file: false,
expected_idx: 1,
check: None,
},
];
for case in cases {
let mut state = make_editor_with_tree();
if case.pre_expand_src {
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes =
build_hierarchical_tree(&state.dir_entries, &state.file_tree.expanded_dirs, "");
state.file_tree.rebuild_visible();
}
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = case.start_idx;
if case.pre_select_file {
state.selected_file = Some("Cargo.toml".to_string());
}
let _ = state.update(case.msg.clone());
assert_eq!(
state.file_tree.tree_focus_index, case.expected_idx,
"case: {}",
case.name
);
if let Some(check) = case.check {
check(&state, case.name);
}
}
}
#[test]
fn test_toggle_dir_sets_tree_focus_index() {
let mut state = make_editor_with_tree();
state.selected_file = Some("Cargo.toml".to_string());
let _ = state.update(EditorMessage::ToggleDir("src".to_string()));
assert!(state.file_tree.tree_focused);
assert_eq!(state.file_tree.tree_focus_index, 0);
assert_eq!(state.file_tree.visible_tree_nodes[0].0, "src");
assert!(
state.selected_file.is_none(),
"ToggleDir should clear selected_file"
);
}
#[test]
fn test_select_file_sets_tree_focus_index() {
let mut state = make_editor_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes =
build_hierarchical_tree(&state.dir_entries, &state.file_tree.expanded_dirs, "");
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
let _ = state.update(EditorMessage::SelectFile("src/main.rs".to_string()));
assert!(state.file_tree.tree_focused);
assert_eq!(
state.file_tree.visible_tree_nodes[state.file_tree.tree_focus_index].0,
"src/main.rs"
);
}
#[test]
fn test_select_file_sets_tree_focused_when_not_focused() {
let mut state = make_editor_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes =
build_hierarchical_tree(&state.dir_entries, &state.file_tree.expanded_dirs, "");
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = false;
let _ = state.update(EditorMessage::SelectFile("src/main.rs".to_string()));
assert!(
state.file_tree.tree_focused,
"SelectFile should set tree_focused to true"
);
}
fn make_editor_with_single_tab(text: &str) -> EditorState {
let mut state = EditorState::new();
state.tabs.push(Tab {
path: "/test.rs".to_string(),
file_name: "test.rs".to_string(),
is_dirty: false,
line_ending: LineEnding::Lf,
});
state.active_tab_index = 0;
state.tab_contents.insert(
"/test.rs".to_string(),
TabData {
content: EditorBuffer::with_text(text, None),
undo_stack: RefCell::new(UndoStack::new()),
find_replace_state: None,
saved_text_hash: hash_text(text),
},
);
state
}
#[test]
fn test_undo_noop_when_quick_open_active() {
let mut state = make_editor_with_single_tab("hello");
let path = "/test.rs".to_string();
if let Some(tab_data) = state.tab_contents.get_mut(&path) {
tab_data
.undo_stack
.borrow_mut()
.snap_before_edit(&tab_data.content);
tab_data.content.perform_action(EditorAction::Insert('!'));
}
state.active_modal = Some(ModalKind::QuickOpen(QuickOpenState {
filter: String::new(),
selected_index: 0,
results: Vec::new(),
}));
let _ = state.update(EditorMessage::Undo);
assert_eq!(
state.tab_contents.get(&path).unwrap().content.text(),
"!hello"
);
}
#[test]
fn test_refresh_file_tree_noop_when_quick_open_active() {
let mut state = make_editor_with_tree();
let initial_gen_count = state.dir_generations.len();
assert!(state.selected_workspace_path.is_some());
state.active_modal = Some(ModalKind::QuickOpen(QuickOpenState {
filter: String::new(),
selected_index: 0,
results: Vec::new(),
}));
let _ = state.update(EditorMessage::RefreshFileTree);
assert_eq!(
state.dir_generations.len(),
initial_gen_count,
"RefreshFileTree must not spawn directory refreshes when a modal overlay is active"
);
}
#[test]
fn test_tree_focus_toggled_noop_during_modal_overlay() {
let mut state = make_editor_with_tree();
let _ = state.update(EditorMessage::TreeFocusToggled);
assert!(state.file_tree.tree_focused);
state.active_modal = Some(ModalKind::QuickOpen(QuickOpenState {
filter: String::new(),
selected_index: 0,
results: Vec::new(),
}));
let _ = state.update(EditorMessage::TreeFocusToggled);
assert!(
state.file_tree.tree_focused,
"TreeFocusToggled must not toggle focus when a modal overlay is active"
);
}
#[test]
fn test_tree_nav_suppressed_during_goto_line_overlay() {
let mut state = make_editor_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes =
build_hierarchical_tree(&state.dir_entries, &state.file_tree.expanded_dirs, "");
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
state.active_modal = Some(ModalKind::GotoLine(String::new()));
let prev_focus = state.file_tree.tree_focus_index;
let nav_msgs: &[EditorMessage] = &[
EditorMessage::TreeNavUp,
EditorMessage::TreeNavDown,
EditorMessage::TreeNavEnter,
EditorMessage::TreeNavLeft,
EditorMessage::TreeNavRight,
];
for msg in nav_msgs {
let _ = state.update(msg.clone());
assert_eq!(
state.file_tree.tree_focus_index, prev_focus,
"{msg:?} should be suppressed during GotoLine overlay"
);
}
let _ = state.update(EditorMessage::TreeFocusToggled);
assert!(
state.file_tree.tree_focused,
"TreeFocusToggled should be suppressed during GotoLine overlay"
);
}
#[test]
fn test_find_replace_all_preserves_cursor() {
let mut state = make_editor_with_single_tab("ab cd ab");
let path = "/test.rs".to_string();
if let Some(tab_data) = state.tab_contents.get_mut(&path) {
tab_data.content.move_to(0, 5);
tab_data.find_replace_state = Some(FindReplaceState {
query: "ab".to_string(),
replace: "xy".to_string(),
matches: vec![0..2, 6..8],
current_match_idx: 0,
case_sensitive: true,
});
}
let _ = state.update(EditorMessage::FindReplaceAll);
let cursor = state.tab_contents.get(&path).unwrap().content.cursor();
assert_eq!(cursor.line, 0);
assert_eq!(cursor.column, 5);
}
#[test]
fn test_quick_open_toggle_blocked_when_goto_line_open() {
let mut state = make_editor_with_single_tab("hello");
state.active_modal = Some(ModalKind::GotoLine(String::new()));
let _ = state.update(EditorMessage::QuickOpenToggle);
assert!(!matches!(state.active_modal, Some(ModalKind::QuickOpen(_))));
}
#[test]
fn test_quick_open_toggle_closes_when_already_open() {
let mut state = make_editor_with_single_tab("hello");
state.active_modal = Some(ModalKind::QuickOpen(QuickOpenState {
filter: "foo".to_string(),
selected_index: 0,
results: Vec::new(),
}));
let _ = state.update(EditorMessage::QuickOpenToggle);
assert!(state.active_modal.is_none());
}
#[test]
fn test_global_search_toggle_blocked_when_quick_open_open() {
let mut state = make_editor_with_single_tab("hello");
state.selected_workspace_name = Some("ws".to_string());
state.selected_workspace_path = Some("/tmp/ws".to_string());
state.active_modal = Some(ModalKind::QuickOpen(QuickOpenState {
filter: String::new(),
selected_index: 0,
results: Vec::new(),
}));
let _ = state.update(EditorMessage::GlobalSearchToggle);
assert!(!matches!(
state.active_modal,
Some(ModalKind::GlobalSearch(_))
));
}
#[test]
fn test_rename_request_sets_target() {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
assert!(matches!(state.active_modal, Some(ModalKind::Rename(_))));
let rt = match state.active_modal {
Some(ModalKind::Rename(ref rt)) => rt.clone(),
_ => panic!("expected Rename modal"),
};
assert_eq!(rt.path, "Cargo.toml");
assert_eq!(rt.input_text, "Cargo.toml");
assert!(!rt.is_dir);
}
#[test]
fn test_rename_request_on_directory_sets_is_dir() {
let tmp_dir = tempfile::tempdir().unwrap();
let dir_path = tmp_dir.path().join("src");
std::fs::create_dir(&dir_path).unwrap();
let mut state = EditorState::new();
state.selected_workspace_path = Some(tmp_dir.path().to_string_lossy().to_string());
state.dir_entries.insert(
String::new(),
vec![FsEntry {
name: "src".to_string(),
full_path: "src".to_string(),
is_dir: true,
error: None,
}],
);
let _ = state.update(EditorMessage::RenameRequested("src".into()));
assert!(matches!(state.active_modal, Some(ModalKind::Rename(_))));
let rt = match state.active_modal {
Some(ModalKind::Rename(ref rt)) => rt.clone(),
_ => panic!("expected Rename modal"),
};
assert_eq!(rt.path, "src");
assert_eq!(rt.input_text, "src");
assert!(rt.is_dir);
}
#[test]
fn test_rename_request_on_root_dir_rejected() {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested(String::new()));
assert!(
state.active_modal.is_none() || !matches!(state.active_modal, Some(ModalKind::Rename(_)))
);
}
#[test]
fn test_rename_input_updates_text_and_clears_error() {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
if let Some(ModalKind::Rename(ref mut rt)) = state.active_modal {
rt.error = Some("bad".into());
}
let _ = state.update(EditorMessage::RenameInput("new_name".into()));
if let Some(ModalKind::Rename(ref rt)) = state.active_modal {
assert_eq!(rt.input_text, "new_name");
assert!(rt.error.is_none());
} else {
panic!("expected Rename modal");
}
}
#[test]
fn test_rename_cancel_and_escape() {
for msg in [EditorMessage::RenameCancel, EditorMessage::Escape] {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
assert!(matches!(state.active_modal, Some(ModalKind::Rename(_))));
let _ = state.update(msg.clone());
assert!(
state.active_modal.is_none(),
"{msg:?} must close the Rename modal"
);
}
}
#[test]
fn test_tree_nav_suppressed_during_rename() {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes =
build_hierarchical_tree(&state.dir_entries, &state.file_tree.expanded_dirs, "");
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
let prev_focus = state.file_tree.tree_focus_index;
let nav_msgs: &[EditorMessage] = &[
EditorMessage::TreeNavUp,
EditorMessage::TreeNavDown,
EditorMessage::TreeNavEnter,
EditorMessage::TreeNavLeft,
EditorMessage::TreeNavRight,
EditorMessage::TreeFocusToggled,
];
for msg in nav_msgs {
let _ = state.update(msg.clone());
assert_eq!(
state.file_tree.tree_focus_index, prev_focus,
"{msg:?} should be suppressed during rename"
);
}
let _ = state.update(EditorMessage::RenameCancel);
let _ = state.update(EditorMessage::TreeNavDown);
assert_ne!(state.file_tree.tree_focus_index, prev_focus);
}
#[test]
fn test_rename_mutual_exclusion_with_new_item() {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
assert!(matches!(state.active_modal, Some(ModalKind::Rename(_))));
let _ = state.update(EditorMessage::NewFileRequested("src".into()));
assert!(
state.active_modal.is_none() || !matches!(state.active_modal, Some(ModalKind::Rename(_)))
);
assert!(matches!(state.active_modal, Some(ModalKind::NewItem(_))));
let _ = state.update(EditorMessage::NewFileRequested(String::new()));
assert!(matches!(state.active_modal, Some(ModalKind::NewItem(_))));
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
assert!(matches!(state.active_modal, Some(ModalKind::Rename(_))));
}
fn setup_rename_state(state: &mut EditorState, input_text: &str) {
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
if let Some(ModalKind::Rename(ref mut rt)) = state.active_modal {
rt.input_text = input_text.to_string();
}
}
fn assert_rename_rejects(input: &str, expected: Option<&'static str>) {
let mut state = make_editor_with_tree();
setup_rename_state(&mut state, input);
let _ = state.update(EditorMessage::RenameSubmit);
let err = match &state.active_modal {
Some(ModalKind::Rename(rt)) => rt.error.as_deref(),
_ => None,
};
assert_eq!(err, expected, "rejection of {input:?}");
}
#[test]
fn test_rename_validation() {
struct Case {
input: &'static str,
expected: Option<&'static str>,
}
let cases: &[Case] = &[
Case {
input: " ",
expected: Some("Name cannot be empty"),
},
Case {
input: "foo/bar.rs",
expected: Some("Name cannot contain path separators"),
},
Case {
input: "foo\\bar.rs",
expected: Some("Name cannot contain path separators"),
},
Case {
input: "foo\0bar.rs",
expected: Some("Name cannot contain path separators"),
},
Case {
input: ".",
expected: Some("Invalid name"),
},
Case {
input: "..",
expected: Some("Invalid name"),
},
];
for case in cases {
assert_rename_rejects(case.input, case.expected);
}
}
#[cfg(target_os = "windows")]
#[test]
fn test_rename_validation_os_reserved_names() {
let reserved = ["con", "NUL", "prn", "AUX", "com1", "lpt3"];
for name in &reserved {
assert_rename_rejects(name, Some("Name is reserved by the operating system"));
}
}
#[test]
fn test_rename_validation_target_already_exists() {
let tmp_dir = tempfile::tempdir().unwrap();
let ws = tmp_dir.path().to_string_lossy().to_string();
let existing = tmp_dir.path().join("existing.txt");
std::fs::write(&existing, "").unwrap();
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some(ws.clone());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
if let Some(ModalKind::Rename(ref mut rt)) = state.active_modal {
rt.input_text = "existing.txt".to_string();
}
let _ = state.update(EditorMessage::RenameSubmit);
let err = match &state.active_modal {
Some(ModalKind::Rename(rt)) => rt.error.as_deref(),
_ => None,
};
assert_eq!(
err,
Some("A file or directory with that name already exists")
);
}
#[test]
fn test_rename_stale_generation_discarded() {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
state.file_tree.expanded_dirs.insert("src".to_string());
state.rebuild_tree();
let _ = state.update(EditorMessage::RenameRequested("src/main.rs".into()));
if let Some(ModalKind::Rename(ref mut rt)) = state.active_modal {
rt.input_text = "lib.rs".to_string();
}
let _ = state.update(EditorMessage::RenameSubmit);
let task = state.update(EditorMessage::RenameCompleted {
old_path: "src/main.rs".into(),
new_path: "src/lib.rs".into(),
is_dir: false,
result: Ok(()),
dir_entries: Ok(vec![]),
rename_gen: 0, });
let _ = task;
let src_entries = state.dir_entries.get("src");
assert!(
src_entries.is_some(),
"dir_entries[\"src\"] should still exist"
);
if let Some(entries) = src_entries {
assert_eq!(entries.len(), 1, "should still have one entry");
assert_eq!(entries[0].name, "main.rs");
assert_eq!(entries[0].full_path, "src/main.rs");
}
assert_eq!(state.selected_file, None);
}
#[test]
fn test_rename_cancelled_by_tree_click() {
let triggers: &[EditorMessage] = &[
EditorMessage::ToggleDir("src".into()),
EditorMessage::SelectFile("src/main.rs".into()),
];
for trigger in triggers {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
assert!(
matches!(state.active_modal, Some(ModalKind::Rename(_))),
"rename should be active before {trigger:?}"
);
let _ = state.update(trigger.clone());
assert!(
state.active_modal.is_none()
|| !matches!(state.active_modal, Some(ModalKind::Rename(_))),
"rename should be cancelled by {trigger:?}"
);
}
}
#[test]
fn test_rename_mutual_exclusion_cancelled_by_other_modals() {
struct Case {
msg: EditorMessage,
check: fn(&EditorState),
}
let cases: &[Case] = &[
Case {
msg: EditorMessage::NewFileRequested("src".into()),
check: |s| assert!(matches!(s.active_modal, Some(ModalKind::NewItem(_)))),
},
Case {
msg: EditorMessage::NewDirectoryRequested("src".into()),
check: |s| assert!(matches!(s.active_modal, Some(ModalKind::NewItem(_)))),
},
Case {
msg: EditorMessage::DeleteFileRequested("other.rs".into()),
check: |s| {
assert!(matches!(s.active_modal, Some(ModalKind::DeleteConfirm(_))));
if let Some(ModalKind::DeleteConfirm(ref target)) = s.active_modal {
assert_eq!(target.path, "other.rs");
}
},
},
Case {
msg: EditorMessage::DeleteDirectoryRequested("src".into()),
check: |s| {
assert!(matches!(s.active_modal, Some(ModalKind::DeleteConfirm(_))));
if let Some(ModalKind::DeleteConfirm(ref target)) = s.active_modal {
assert_eq!(target.path, "src");
}
},
},
];
for case in cases {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
let _ = state.update(EditorMessage::RenameRequested("Cargo.toml".into()));
assert!(
matches!(state.active_modal, Some(ModalKind::Rename(_))),
"case {:?}",
case.msg
);
let _ = state.update(case.msg.clone());
assert!(
!matches!(state.active_modal, Some(ModalKind::Rename(_))),
"rename should be cancelled by {:?}",
case.msg
);
(case.check)(&state);
}
}
#[test]
fn test_rekey_keys() {
#[rustfmt::skip]
#[expect(clippy::type_complexity)]
let cases: &[(&str, &str, &str, &[&str], &[(&str, &str)])] = &[
("empty", "old/", "new/", &[], &[]),
("no_match", "old/", "new/", &["a", "b"], &[]),
(
"some_match",
"old/",
"new",
&["old/foo", "other", "old/bar/baz"],
&[("old/bar/baz", "new/bar/baz"), ("old/foo", "new/foo")],
),
("exact_prefix", "dir", "newdir", &["dir"], &[("dir", "newdir")]),
];
for &(name, old_prefix, new_prefix, keys, expected) in cases {
let mut pairs = rekey_keys(
old_prefix,
new_prefix,
keys.iter().map(std::string::ToString::to_string),
);
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let got: Vec<(&str, &str)> = pairs
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
assert_eq!(got, expected, "case: {name}");
}
}
#[test]
fn test_rekey_map_prefix_no_modify() {
let mut map = HashMap::from([
("dir/file.rs".to_string(), "content_a".to_string()),
("dir/sub/file.rs".to_string(), "content_b".to_string()),
("other".to_string(), "content_c".to_string()),
]);
rekey_map_prefix(&mut map, "dir/", "newdir", |_| {});
assert_eq!(map.len(), 3);
assert_eq!(map.get("newdir/file.rs"), Some(&"content_a".to_string()));
assert_eq!(
map.get("newdir/sub/file.rs"),
Some(&"content_b".to_string())
);
assert_eq!(map.get("other"), Some(&"content_c".to_string()));
assert!(!map.contains_key("dir/file.rs"));
}
#[test]
fn test_rekey_map_prefix_with_modify() {
let mut map = HashMap::from([
("old/key".to_string(), vec![1, 2]),
("old/other".to_string(), vec![3]),
("keep".to_string(), vec![4]),
]);
rekey_map_prefix(&mut map, "old/", "new", |v: &mut Vec<i32>| v.push(99));
assert_eq!(map.len(), 3);
assert_eq!(map.get("new/key"), Some(&vec![1, 2, 99]));
assert_eq!(map.get("new/other"), Some(&vec![3, 99]));
assert_eq!(map.get("keep"), Some(&vec![4]));
}
#[test]
fn test_rekey_set_prefix_basic() {
let mut set = HashSet::from(["a/x".to_string(), "a/y".to_string(), "b/z".to_string()]);
rekey_set_prefix(&mut set, "a/", "b");
assert_eq!(set.len(), 3);
assert!(set.contains("b/x"));
assert!(set.contains("b/y"));
assert!(set.contains("b/z"));
}
#[test]
fn test_rekey_set_prefix_exact() {
let mut set = HashSet::from(["dir".to_string()]);
rekey_set_prefix(&mut set, "dir", "newdir");
assert_eq!(set.len(), 1);
assert!(set.contains("newdir"));
assert!(!set.contains("dir"));
}
#[test]
fn test_rename_dir_entries_migration_own_entry_and_full_path() {
let mut state = make_editor_with_tree();
state.selected_workspace_path = Some("/tmp".to_string());
state.file_tree.expanded_dirs.insert("src".to_string());
state.dir_entries.insert(
"src/subdir".to_string(),
vec![FsEntry {
name: "helper.rs".to_string(),
full_path: "src/subdir/helper.rs".to_string(),
is_dir: false,
error: None,
}],
);
state.dir_generations.insert(String::new(), 0);
let _ = state.update(EditorMessage::RenameCompleted {
old_path: "src".into(),
new_path: "lib".into(),
is_dir: true,
result: Ok(()),
dir_entries: Ok(vec![FsEntry {
name: "lib".to_string(),
full_path: "lib".to_string(),
is_dir: true,
error: None,
}]),
rename_gen: 0,
});
assert!(
!state.dir_entries.contains_key("src"),
"old path key should be removed"
);
let own_entries = state.dir_entries.get("lib");
assert!(
own_entries.is_some(),
"new path key should exist for the renamed directory"
);
if let Some(entries) = own_entries {
assert_eq!(entries.len(), 1, "src had one child (main.rs)");
assert_eq!(entries[0].full_path, "lib/main.rs");
}
let child_entries = state.dir_entries.get("lib/subdir");
assert!(
child_entries.is_some(),
"child dir_entries key should be migrated"
);
if let Some(entries) = child_entries {
if let Some(entry) = entries.first() {
assert_eq!(
entry.full_path, "lib/subdir/helper.rs",
"entry full_path should be updated to new prefix"
);
}
}
assert!(
!state.file_tree.expanded_dirs.contains("src"),
"old expanded_dir should be removed"
);
assert!(
state.file_tree.expanded_dirs.contains("lib"),
"new expanded_dir should exist"
);
}
fn make_editor_with_tabs(count: usize, active: usize) -> EditorState {
assert!(
active < count,
"active tab index must be less than tab count"
);
let mut state = EditorState::new();
for i in 0..count {
state.tabs.push(Tab {
path: format!("/tmp/test_{i}.rs"),
file_name: format!("test_{i}.rs"),
is_dirty: false,
line_ending: LineEnding::Lf,
});
state.tab_contents.insert(
format!("/tmp/test_{i}.rs"),
TabData {
content: EditorBuffer::with_text("", None),
undo_stack: RefCell::new(UndoStack::new()),
find_replace_state: None,
saved_text_hash: 0,
},
);
}
state.active_tab_index = active;
state
}
#[test]
fn test_switch_tab_relative() {
struct Case {
name: &'static str,
tabs: usize,
start: usize,
direction: TabDirection,
expected: usize,
}
let cases: &[Case] = &[
Case {
name: "single tab next",
tabs: 1,
start: 0,
direction: TabDirection::Next,
expected: 0,
},
Case {
name: "single tab prev",
tabs: 1,
start: 0,
direction: TabDirection::Prev,
expected: 0,
},
Case {
name: "next wraps to first",
tabs: 3,
start: 2,
direction: TabDirection::Next,
expected: 0,
},
Case {
name: "prev wraps to last",
tabs: 3,
start: 0,
direction: TabDirection::Prev,
expected: 2,
},
Case {
name: "middle next",
tabs: 3,
start: 1,
direction: TabDirection::Next,
expected: 2,
},
Case {
name: "middle prev",
tabs: 3,
start: 1,
direction: TabDirection::Prev,
expected: 0,
},
];
for case in cases {
let mut state = make_editor_with_tabs(case.tabs, case.start);
let _ = state.switch_tab_relative(case.direction);
assert_eq!(state.active_tab_index, case.expected, "{}", case.name);
}
}
#[test]
fn test_switch_tab_relative_two_tabs() {
let mut state = make_editor_with_tabs(2, 0);
let _ = state.switch_tab_relative(TabDirection::Next);
assert_eq!(state.active_tab_index, 1);
let _ = state.switch_tab_relative(TabDirection::Next);
assert_eq!(state.active_tab_index, 0);
let _ = state.switch_tab_relative(TabDirection::Prev);
assert_eq!(state.active_tab_index, 1);
}
#[test]
fn test_classify_read_dir_error_not_found_vs_other() {
use std::io::ErrorKind;
assert!(matches!(
classify_read_dir_error(&std::io::Error::new(ErrorKind::NotFound, "gone")),
ReadDirError::NotFound
));
assert!(matches!(
classify_read_dir_error(&std::io::Error::new(ErrorKind::NotADirectory, "file")),
ReadDirError::NotFound
));
assert!(matches!(
classify_read_dir_error(&std::io::Error::new(ErrorKind::PermissionDenied, "locked")),
ReadDirError::Other(_)
));
}
fn make_editor_with_expanded_subdir() -> EditorState {
let mut state = make_editor_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.expanded_dirs.insert("src/sub".to_string());
state.dir_entries.insert(
"src/sub".to_string(),
vec![FsEntry {
name: "deep.rs".to_string(),
full_path: "src/sub/deep.rs".to_string(),
is_dir: false,
error: None,
}],
);
state.dir_entries.get_mut("src").unwrap().push(FsEntry {
name: "sub".to_string(),
full_path: "src/sub".to_string(),
is_dir: true,
error: None,
});
state.loading_dirs.insert("src/sub".to_string());
state.dir_generations.insert("src".to_string(), 3);
state.dir_generations.insert("src/sub".to_string(), 7);
state.selected_file = Some("src/sub/deep.rs".to_string());
state.all_workspace_files = vec![
"Cargo.toml".to_string(),
"src/main.rs".to_string(),
"src/sub/deep.rs".to_string(),
];
state
.file_mtimes
.insert("/tmp/src/main.rs".to_string(), std::time::SystemTime::now());
state
.file_generations
.insert("/tmp/src/main.rs".to_string(), 1);
state
.deleted_file_toasted
.insert("/tmp/src/main.rs".to_string());
state
}
#[test]
fn test_dir_expanded_not_found_forgets_silently() {
for quiet in [true, false] {
let mut state = make_editor_with_expanded_subdir();
let r#gen = state.bump_generation();
state.dir_generations.insert("src".to_string(), r#gen);
let _ = state.update(EditorMessage::DirExpanded {
dir_path: "src".to_string(),
r#gen,
entries: Err(ReadDirError::NotFound),
quiet,
});
assert!(!state.file_tree.expanded_dirs.contains("src"));
assert!(!state.file_tree.expanded_dirs.contains("src/sub"));
assert!(!state.dir_entries.contains_key("src"));
assert!(!state.dir_entries.contains_key("src/sub"));
assert!(!state.loading_dirs.contains("src/sub"));
assert!(!state.dir_generations.contains_key("src"));
assert!(!state.dir_generations.contains_key("src/sub"));
assert_eq!(state.selected_file, None);
assert!(
!state
.all_workspace_files
.iter()
.any(|p| p.starts_with("src/"))
);
let root_entries = state.dir_entries.get("").unwrap();
assert!(!root_entries.iter().any(|e| e.full_path == "src"));
assert!(
!state
.file_tree
.visible_tree_nodes
.iter()
.any(|(p, _)| p == "src")
);
}
}
#[test]
fn test_dir_expanded_other_error_keeps_path() {
let mut state = make_editor_with_expanded_subdir();
let r#gen = state.bump_generation();
state.dir_generations.insert("src".to_string(), r#gen);
let _ = state.update(EditorMessage::DirExpanded {
dir_path: "src".to_string(),
r#gen,
entries: Err(ReadDirError::Other("Permission denied".to_string())),
quiet: true,
});
assert!(state.file_tree.expanded_dirs.contains("src"));
assert!(state.file_tree.expanded_dirs.contains("src/sub"));
assert!(state.dir_entries.contains_key("src"));
assert!(state.dir_entries.contains_key("src/sub"));
assert_eq!(state.selected_file.as_deref(), Some("src/sub/deep.rs"));
assert!(state.all_workspace_files.iter().any(|p| p == "src/main.rs"));
}
#[test]
fn test_forget_directory_prunes_path_descendants_and_root() {
let mut state = make_editor_with_expanded_subdir();
state.dir_generations.insert(String::new(), 9); state.forget_directory("src");
assert!(state.file_tree.expanded_dirs.is_empty());
assert!(!state.dir_entries.contains_key("src"));
assert!(!state.dir_entries.contains_key("src/sub"));
assert!(state.loading_dirs.is_empty());
assert!(!state.dir_generations.contains_key("src"));
assert!(!state.dir_generations.contains_key("src/sub"));
assert_eq!(state.dir_generations.get(""), Some(&9));
assert_eq!(state.selected_file, None);
assert_eq!(state.all_workspace_files, vec!["Cargo.toml".to_string()]);
let root_entries = state.dir_entries.get("").unwrap();
assert_eq!(root_entries.len(), 1);
assert_eq!(root_entries[0].full_path, "Cargo.toml");
assert!(state.file_mtimes.is_empty());
assert!(state.file_generations.is_empty());
assert!(state.deleted_file_toasted.is_empty());
state.rebuild_tree();
assert!(
!state
.file_tree
.visible_tree_nodes
.iter()
.any(|(p, _)| p == "src")
);
state
.file_mtimes
.insert("/tmp/Cargo.toml".to_string(), std::time::SystemTime::now());
state
.file_generations
.insert("/tmp/Cargo.toml".to_string(), 4);
state
.deleted_file_toasted
.insert("/tmp/Cargo.toml".to_string());
state.forget_directory("");
assert!(state.dir_entries.is_empty());
assert!(state.file_tree.expanded_dirs.is_empty());
assert!(state.loading_dirs.is_empty());
assert!(state.dir_generations.is_empty());
assert_eq!(state.selected_file, None);
assert!(state.all_workspace_files.is_empty());
assert!(state.file_mtimes.is_empty());
assert!(state.file_generations.is_empty());
assert!(state.deleted_file_toasted.is_empty());
state.rebuild_tree();
assert!(state.file_tree.nodes.is_empty());
assert!(state.file_tree.visible_tree_nodes.is_empty());
}
#[test]
fn test_dir_deleted_prunes_and_refreshes_parent() {
let mut state = make_editor_with_expanded_subdir();
let _ = state.update(EditorMessage::DirDeleted {
dir_path: "src".to_string(),
workspace_path: "/tmp".to_string(),
});
assert!(!state.file_tree.expanded_dirs.contains("src"));
assert!(!state.file_tree.expanded_dirs.contains("src/sub"));
assert!(!state.dir_entries.contains_key("src"));
assert!(!state.dir_entries.contains_key("src/sub"));
assert_eq!(state.selected_file, None);
assert!(
!state
.all_workspace_files
.iter()
.any(|p| p.starts_with("src/"))
);
let root_entries = state.dir_entries.get("").unwrap();
assert!(!root_entries.iter().any(|e| e.full_path == "src"));
assert_eq!(root_entries.len(), 1);
assert_eq!(root_entries[0].full_path, "Cargo.toml");
assert!(state.dir_generations.contains_key(""));
}
#[test]
fn test_dir_deleted_stale_workspace_guard() {
let mut state = make_editor_with_expanded_subdir();
let _ = state.update(EditorMessage::DirDeleted {
dir_path: "src".to_string(),
workspace_path: "/some/other/workspace".to_string(),
});
assert!(state.file_tree.expanded_dirs.contains("src"));
assert!(state.file_tree.expanded_dirs.contains("src/sub"));
assert!(state.dir_entries.contains_key("src"));
assert!(state.dir_entries.contains_key("src/sub"));
assert_eq!(state.selected_file.as_deref(), Some("src/sub/deep.rs"));
assert_eq!(state.all_workspace_files.len(), 3);
assert!(!state.dir_generations.contains_key(""));
assert!(
state
.file_tree
.visible_tree_nodes
.iter()
.any(|(p, _)| p == "src")
);
}
#[test]
fn test_confirm_delete_dir_defers_prune_until_success() {
let mut state = make_editor_with_expanded_subdir();
state.active_modal = Some(ModalKind::DeleteConfirm(DeleteConfirmTarget {
path: "src".to_string(),
is_dir: true,
dirty_tab_count: 0,
abs_path: "/tmp/src".to_string(),
}));
let _ = state.update(EditorMessage::ConfirmDelete);
assert!(state.active_modal.is_none());
assert!(state.file_tree.expanded_dirs.contains("src"));
assert!(state.file_tree.expanded_dirs.contains("src/sub"));
assert!(state.dir_entries.contains_key("src"));
assert!(state.dir_entries.contains_key("src/sub"));
assert_eq!(state.selected_file.as_deref(), Some("src/sub/deep.rs"));
assert_eq!(state.all_workspace_files.len(), 3);
assert!(state.file_mtimes.contains_key("/tmp/src/main.rs"));
assert!(state.file_generations.contains_key("/tmp/src/main.rs"));
assert!(!state.dir_generations.contains_key(""));
assert_eq!(state.dir_generations.len(), 2); }