use std::cell::Cell;
use std::fmt::Write as _;
use std::time::Duration;
use super::super::{Action, Lines, list as list_module, read as read_module, search as search_module};
use super::{Scratch, list, perform, read, search};
fn numbered(total: usize) -> String {
(1..=total).fold(String::new(), |mut text, number| {
let _ = writeln!(text, "line {number}");
text
})
}
fn read_between(path: &str, start: i64, end: i64) -> Action {
Action::Read { path: path.to_owned(), lines: Lines { start: Some(start), end: Some(end) } }
}
fn list_at(path: &str, depth: i64) -> Action {
Action::List { path: path.to_owned(), depth: Some(depth) }
}
fn search_glob(pattern: &str, glob: &str) -> Action {
Action::Search { pattern: pattern.to_owned(), path: String::new(), glob: Some(glob.to_owned()), ignore_case: false }
}
fn search_any_case(pattern: &str) -> Action {
Action::Search { pattern: pattern.to_owned(), path: String::new(), glob: None, ignore_case: true }
}
#[test]
fn read_returns_the_plain_text() {
let scratch = Scratch::new("read");
scratch.write("src/main.rs", "fn main() {}\n");
let outcome = perform(&scratch.workspace(), &read("src/main.rs"));
assert!(!outcome.is_error);
assert_eq!(outcome.text, "fn main() {}\n");
}
#[test]
fn read_refuses_what_is_not_text() {
let scratch = Scratch::new("binary");
scratch.write("nul.bin", b"ab\0cd");
scratch.write("latin.bin", [0xff_u8, 0xfe, 0x41]);
let workspace = scratch.workspace();
for name in ["nul.bin", "latin.bin"] {
let outcome = perform(&workspace, &read(name));
assert!(outcome.is_error);
assert!(outcome.text.contains("is not a text file"), "{}", outcome.text);
}
}
#[test]
fn read_names_folders_missing_files_and_empty_files() {
let scratch = Scratch::new("read-kinds");
scratch.write("sub/empty.txt", "");
let workspace = scratch.workspace();
let folder = perform(&workspace, &read("sub"));
assert!(folder.is_error && folder.text.contains("use list_dir"), "{}", folder.text);
let missing = perform(&workspace, &read("nope.txt"));
assert!(missing.is_error);
assert_eq!(missing.text, "`nope.txt` does not exist.");
let empty = perform(&workspace, &read("sub/empty.txt"));
assert!(!empty.is_error);
assert_eq!(empty.text, "`sub/empty.txt` is empty.");
}
#[test]
fn a_long_file_is_cut_by_lines_and_says_so() {
let scratch = Scratch::new("read-lines");
let total = read_module::MAX_LINES + 500;
let content: String = (1..=total).fold(String::new(), |mut text, number| {
let _ = writeln!(text, "line {number}");
text
});
scratch.write("long.txt", &content);
let outcome = perform(&scratch.workspace(), &read("long.txt"));
assert!(!outcome.is_error);
let last_shown = format!("line {}\n", read_module::MAX_LINES);
assert!(outcome.text.contains(&last_shown));
assert!(!outcome.text.contains(&format!("line {}\n", read_module::MAX_LINES + 1)));
assert!(outcome.text.ends_with(&format!(
"(Cut: the file has {total} lines; only the first {} are shown.)",
read_module::MAX_LINES
)));
}
#[test]
fn a_wide_file_is_cut_by_bytes() {
let scratch = Scratch::new("read-bytes");
let line = format!("{}\n", "x".repeat(999));
scratch.write("wide.txt", line.repeat(300));
let outcome = perform(&scratch.workspace(), &read("wide.txt"));
assert!(
outcome.text.ends_with("(Cut: the file has 300 lines; only the first 102 are shown.)"),
"{}",
&outcome.text[outcome.text.len() - 80..]
);
assert!(outcome.text.len() <= read_module::MAX_BYTES + 100);
scratch.write("one.txt", "é".repeat(read_module::MAX_BYTES));
let outcome = perform(&scratch.workspace(), &read("one.txt"));
assert!(outcome.text.contains("only its start is shown"), "cut note");
assert!(outcome.text.len() <= read_module::MAX_BYTES + 200);
}
#[test]
fn a_range_of_a_long_file_gives_those_lines_and_says_what_is_left_out() {
let scratch = Scratch::new("read-range");
scratch.write("long.txt", numbered(3000));
let outcome = perform(&scratch.workspace(), &read_between("long.txt", 500, 700));
assert!(!outcome.is_error);
assert!(outcome.text.starts_with("line 500\nline 501\n"), "{}", &outcome.text[..40]);
assert!(outcome.text.contains("line 700\n"));
assert!(!outcome.text.contains("line 499"));
assert!(!outcome.text.contains("line 701"));
assert!(
outcome.text.ends_with("(Lines 500-700 of 3000. Not shown: 499 lines above and 2300 lines below.)"),
"{}",
outcome.text
);
let lines: Vec<&str> = outcome.text.lines().collect();
assert_eq!(lines.len(), 202, "the 201 lines and the note under them");
}
#[test]
fn a_range_may_name_only_one_of_its_ends() {
let scratch = Scratch::new("read-one-end");
scratch.write("long.txt", numbered(10));
let workspace = scratch.workspace();
let head = perform(&workspace, &read_between("long.txt", 8, 10));
assert!(head.text.ends_with("(Lines 8-10 of 10. Not shown: 7 lines above.)"), "{}", head.text);
let past = perform(&workspace, &read_between("long.txt", 8, 9999));
assert!(!past.is_error);
assert!(past.text.ends_with("(Lines 8-10 of 10. Not shown: 7 lines above.)"), "{}", past.text);
let from =
perform(&workspace, &Action::Read { path: "long.txt".to_owned(), lines: Lines { start: Some(9), end: None } });
assert!(from.text.ends_with("(Lines 9-10 of 10. Not shown: 8 lines above.)"), "{}", from.text);
let all = perform(&workspace, &read_between("long.txt", 1, 10));
assert!(all.text.ends_with("(The whole file: 10 lines.)"), "{}", all.text);
assert_eq!(all.text.matches("line ").count(), 10, "every line, once");
}
#[test]
fn a_range_that_cannot_be_used_is_a_sentence_and_nothing_is_read() {
let scratch = Scratch::new("read-bad-range");
scratch.write("long.txt", numbered(3000));
scratch.write("nul.bin", b"ab\0cd");
let workspace = scratch.workspace();
let zero = perform(&workspace, &read_between("long.txt", 0, 700));
assert!(zero.is_error);
assert_eq!(zero.text, "Lines are counted from 1, so `start_line` must be 1 or more.");
let negative = perform(&workspace, &read_between("long.txt", 500, -5));
assert!(negative.is_error);
assert_eq!(negative.text, "Lines are counted from 1, so `end_line` must be 1 or more.");
let inverted = perform(&workspace, &read_between("long.txt", 700, 500));
assert!(inverted.is_error);
assert!(inverted.text.contains("`end_line` is before `start_line`"), "{}", inverted.text);
let past = perform(&workspace, &read_between("long.txt", 3001, 4000));
assert!(past.is_error);
assert_eq!(past.text, "The file has 3000 lines, so `start_line` 3001 is past its end.");
let binary = perform(&workspace, &read_between("nul.bin", 1, 2));
assert!(binary.is_error && binary.text.contains("is not a text file"), "{}", binary.text);
assert_eq!(scratch.read("long.txt"), numbered(3000), "the file is as it was");
}
#[test]
fn a_range_is_cut_by_the_caps_as_the_head_is() {
let scratch = Scratch::new("read-range-caps");
let total = read_module::MAX_LINES + 500;
scratch.write("many.txt", numbered(total));
let by_lines = perform(&scratch.workspace(), &read_between("many.txt", 1, total as i64));
assert!(by_lines.text.contains("line 2000\n"), "the cap's last line is there");
assert!(!by_lines.text.contains("line 2001\n"), "and the one after it is not");
assert!(
by_lines.text.ends_with(&format!(
"(Lines 1-2000 of {total}. The 2000-line cap stopped it there. Not shown: 500 lines below.)"
)),
"the note says which end the cap cut: {}",
&by_lines.text[by_lines.text.len() - 120..]
);
let wide = format!("{}\n", "x".repeat(999));
scratch.write("wide.txt", wide.repeat(300));
let by_bytes = perform(&scratch.workspace(), &read_between("wide.txt", 1, 300));
assert!(
by_bytes.text.ends_with("(Lines 1-102 of 300. The 100 KB cap stopped it there. Not shown: 198 lines below.)")
);
assert!(by_bytes.text.len() <= read_module::MAX_BYTES + 100);
scratch.write("one.txt", format!("{}{}", "é".repeat(read_module::MAX_BYTES), "tail\n"));
let one = perform(&scratch.workspace(), &read_between("one.txt", 1, 2));
assert!(one.text.contains("only its start is shown"), "{}", &one.text[one.text.len() - 200..]);
assert!(one.text.len() <= read_module::MAX_BYTES + 200);
}
#[test]
fn list_puts_folders_first_with_a_slash_in_name_order() {
let scratch = Scratch::new("list");
scratch.write("b.txt", "");
scratch.write("a.txt", "");
scratch.write("zeta/x", "");
scratch.write("alpha/x", "");
let outcome = perform(&scratch.workspace(), &list(""));
assert!(!outcome.is_error);
assert_eq!(outcome.text, "alpha/\nzeta/\na.txt\nb.txt");
assert_eq!(perform(&scratch.workspace(), &list("zeta")).text, "x");
}
#[test]
fn list_is_capped_and_says_how_many_more() {
let scratch = Scratch::new("list-cap");
for number in 0..list_module::MAX_ENTRIES + 7 {
scratch.write(&format!("f{number:04}"), "");
}
let outcome = perform(&scratch.workspace(), &list("."));
let lines: Vec<&str> = outcome.text.lines().collect();
assert_eq!(lines.len(), list_module::MAX_ENTRIES + 1);
assert_eq!(lines[0], "f0000");
assert_eq!(lines[lines.len() - 1], "(… and 7 more entries not shown.)");
}
#[test]
fn list_names_files_and_empty_folders() {
let scratch = Scratch::new("list-kinds");
scratch.write("a.txt", "x");
std::fs::create_dir(scratch.path().join("empty")).unwrap();
let workspace = scratch.workspace();
let file = perform(&workspace, &list("a.txt"));
assert!(file.is_error && file.text.contains("use read_file"), "{}", file.text);
assert_eq!(perform(&workspace, &list("empty")).text, "`empty` is empty.");
assert!(perform(&workspace, &list("nope")).is_error);
}
fn tree_scratch(label: &str) -> Scratch {
let scratch = Scratch::new(label);
scratch.write("alpha/one.txt", "0123456789");
scratch.write("alpha/two.md", "x".repeat(2500));
scratch.write("zeta/a.rs", "a");
scratch.write("zeta/deep/b.rs", "b");
scratch.write("top.txt", "top");
scratch.write(".git/config", "git");
scratch.write("target/debug/out", "out");
scratch.write("node_modules/pkg/index.js", "js");
scratch
}
#[test]
fn a_listing_with_a_depth_is_a_tree_of_names_and_sizes() {
let scratch = tree_scratch("list-tree");
let workspace = scratch.workspace();
let outcome = perform(&workspace, &list_at("", 2));
assert!(!outcome.is_error);
assert_eq!(
outcome.text,
".git/\n\
alpha/\n\
\x20 one.txt 10 B\n\
\x20 two.md 2.4 KB\n\
node_modules/\n\
target/\n\
zeta/\n\
\x20 deep/\n\
\x20 a.rs 1 B\n\
top.txt 3 B"
);
let deeper = perform(&workspace, &list_at("", 3));
assert_eq!(
deeper.text,
".git/\n\
alpha/\n\
\x20 one.txt 10 B\n\
\x20 two.md 2.4 KB\n\
node_modules/\n\
target/\n\
zeta/\n\
\x20 deep/\n\
\x20 b.rs 1 B\n\
\x20 a.rs 1 B\n\
top.txt 3 B",
"the tree goes one folder deeper and never into .git, target or node_modules"
);
assert!(perform(&workspace, &list("")).text.starts_with(".git/\nalpha/\nnode_modules/\n"));
}
#[test]
fn a_depth_over_the_maximum_names_it_instead_of_being_capped() {
let scratch = tree_scratch("list-depth");
let workspace = scratch.workspace();
let deep = perform(&workspace, &list_at("", list_module::MAX_DEPTH + 1));
assert!(deep.is_error);
assert_eq!(deep.text, "A depth of 4 is not one; `list_dir` takes a depth from 1 to 3.");
for depth in [0, -1] {
let wrong = perform(&workspace, &list_at("", depth));
assert!(wrong.is_error, "a depth of {depth}");
assert!(wrong.text.contains("from 1 to 3"), "{}", wrong.text);
}
assert!(!perform(&workspace, &list_at("", list_module::MAX_DEPTH)).is_error, "the maximum itself is fine");
}
#[test]
fn a_listing_of_a_tree_never_follows_a_link_out_of_the_folder() {
use std::os::unix::fs::symlink;
let outside = Scratch::new("list-link-target");
outside.write("secret.txt", "secret\n");
let scratch = Scratch::new("list-links");
scratch.write("inside/a.txt", "a");
symlink(outside.path(), scratch.path().join("away")).unwrap();
let outcome = perform(&scratch.workspace(), &list_at("", 3));
assert!(outcome.text.contains("away (a link out of the working folder)"), "{}", outcome.text);
assert!(!outcome.text.contains("away/"), "{}", outcome.text);
assert!(!outcome.text.contains("secret.txt"), "{}", outcome.text);
}
#[test]
fn a_tree_never_tells_the_size_of_a_file_a_link_leads_out_to() {
use std::os::unix::fs::symlink;
let outside = Scratch::new("list-size-target");
outside.write("secret.txt", "s".repeat(7777));
let scratch = Scratch::new("list-size-links");
scratch.write("inside.txt", "a");
symlink(outside.path().join("secret.txt"), scratch.path().join("peek")).unwrap();
let workspace = scratch.workspace();
for depth in [2, 3] {
let outcome = perform(&workspace, &list_at("", depth));
assert!(outcome.text.contains("peek (a link out of the working folder)"), "depth {depth}: {}", outcome.text);
assert!(!outcome.text.contains("7.6 KB"), "depth {depth}: {}", outcome.text);
assert!(!outcome.text.contains("7777"), "depth {depth}: {}", outcome.text);
}
let shallow = perform(&workspace, &list_at("", 1));
assert!(shallow.text.contains("peek (a link out of the working folder)"), "{}", shallow.text);
assert!(!shallow.text.contains("7.6 KB"), "{}", shallow.text);
}
#[test]
fn a_broken_link_is_named_as_leading_out() {
use std::os::unix::fs::symlink;
let outside = Scratch::new("list-broken-target");
let scratch = Scratch::new("list-broken");
scratch.write("inside.txt", "a");
symlink(outside.path().join("not-there.txt"), scratch.path().join("gone")).unwrap();
let outcome = perform(&scratch.workspace(), &list_at("", 2));
assert!(outcome.text.contains("gone (a link out of the working folder)"), "{}", outcome.text);
}
#[test]
fn a_link_inside_keeps_its_size_and_folder() {
use std::os::unix::fs::symlink;
let scratch = Scratch::new("list-inside-links");
scratch.write("real/b.txt", "b".repeat(3000));
symlink(scratch.path().join("real/b.txt"), scratch.path().join("alias")).unwrap();
symlink(scratch.path().join("real"), scratch.path().join("dir-alias")).unwrap();
let outcome = perform(&scratch.workspace(), &list_at("", 2));
assert!(!outcome.is_error);
let alias = outcome.text.lines().find(|line| line.starts_with("alias ")).expect("the alias line");
assert!(alias.contains("2.9 KB"), "{alias}");
assert!(outcome.text.lines().any(|line| line == "dir-alias/"), "{}", outcome.text);
assert!(!outcome.text.contains("out of the working folder"), "{}", outcome.text);
}
#[test]
fn search_finds_lines_in_a_sorted_walk() {
let scratch = Scratch::new("search");
scratch.write("b.rs", "fn main() {}\nlet x = 1;\n");
scratch.write("a/z.rs", "// no\nfn main_helper() {}\n");
scratch.write("a/y.rs", "FN MAIN\n");
let outcome = perform(&scratch.workspace(), &search("fn main", ""));
assert!(!outcome.is_error);
assert_eq!(outcome.text, "a/z.rs:2: fn main_helper() {}\nb.rs:1: fn main() {}");
let narrowed = perform(&scratch.workspace(), &search("fn main", "a"));
assert_eq!(narrowed.text, "a/z.rs:2: fn main_helper() {}");
let one_file = perform(&scratch.workspace(), &search("let", "b.rs"));
assert_eq!(one_file.text, "b.rs:2: let x = 1;");
}
#[test]
fn search_skips_history_build_output_and_binaries() {
let scratch = Scratch::new("search-skip");
scratch.write(".git/config", "needle\n");
scratch.write("target/debug/out.txt", "needle\n");
scratch.write("node_modules/pkg/index.js", "needle\n");
scratch.write("data.bin", b"needle\n\0\x01");
scratch.write("src/lib.rs", "needle\n");
scratch.write("src/target", "needle\n");
let outcome = perform(&scratch.workspace(), &search("needle", ""));
assert_eq!(outcome.text, "src/lib.rs:1: needle\nsrc/target:1: needle");
}
#[test]
fn search_is_capped_and_says_so() {
let scratch = Scratch::new("search-cap");
scratch.write("many.txt", "hit\n".repeat(search_module::MAX_MATCHES + 50));
let outcome = perform(&scratch.workspace(), &search("hit", ""));
let lines: Vec<&str> = outcome.text.lines().collect();
assert_eq!(lines.len(), search_module::MAX_MATCHES + 1);
assert!(lines[lines.len() - 1].starts_with("(Stopped at 200 matches"));
}
#[test]
fn search_reports_nothing_found_and_empty_patterns() {
let scratch = Scratch::new("search-none");
scratch.write("a.txt", "hello\n");
let workspace = scratch.workspace();
let none = perform(&workspace, &search("absent", ""));
assert!(!none.is_error);
assert_eq!(
none.text,
"No matches for `absent` in `.`. Try `ignore_case` for the same text in another case; \
or a `glob` such as `*.rs` to look at fewer files."
);
assert!(perform(&workspace, &search("", "")).is_error);
assert!(perform(&workspace, &search("x", "nope")).is_error);
}
#[test]
fn search_shortens_very_long_lines() {
let scratch = Scratch::new("search-long");
scratch.write("a.txt", format!("needle{}\n", "y".repeat(1000)));
let outcome = perform(&scratch.workspace(), &search("needle", ""));
assert!(outcome.text.ends_with('…'));
assert!(outcome.text.chars().count() < 330);
}
#[test]
fn a_search_can_ignore_case_and_another_can_be_narrowed_by_a_glob() {
let scratch = Scratch::new("search-narrow");
scratch.write("src/main.rs", "let widget = 1;\n");
scratch.write("src/other.txt", "a widget of my own\n");
scratch.write("src/note.txt", "the Widget is here\n");
scratch.write("docs/guide.md", "a widget, lower case\n");
let workspace = scratch.workspace();
let exact = perform(&workspace, &search("widget", ""));
assert_eq!(
exact.text,
"docs/guide.md:1: a widget, lower case\nsrc/main.rs:1: let widget = 1;\nsrc/other.txt:1: a widget of my own"
);
let any = perform(&workspace, &search_any_case("widget"));
assert_eq!(
any.text,
"docs/guide.md:1: a widget, lower case\nsrc/main.rs:1: let widget = 1;\n\
src/note.txt:1: the Widget is here\nsrc/other.txt:1: a widget of my own"
);
let one_folder = perform(&workspace, &search_glob("widget", "src/*"));
assert_eq!(one_folder.text, "src/main.rs:1: let widget = 1;\nsrc/other.txt:1: a widget of my own");
let rust = perform(&workspace, &search_glob("widget", "*.rs"));
assert_eq!(rust.text, "src/main.rs:1: let widget = 1;");
let everywhere = perform(&workspace, &search_glob("widget", "*"));
assert_eq!(everywhere.text, exact.text);
let none = perform(&workspace, &search_glob("widget", "*.toml"));
assert_eq!(none.text, "No matches for `widget` in `.`. Try `ignore_case` for the same text in another case.");
}
#[test]
fn the_glob_syntax_is_a_star_for_any_run_and_a_question_mark_for_one() {
let matches = |pattern: &str, path: &str| search_module::glob_matches(pattern, path);
assert!(matches("*.rs", "src/main.rs"), "`*` stands for folders too");
assert!(matches("src/*.rs", "src/main.rs"));
assert!(matches("src/*.rs", "src/deep/main.rs"), "a `*` crosses the folder separator as well");
assert!(!matches("src/*", "docs/guide.md"), "what is written out has to be there");
assert!(!matches("src/*", "other/src/main.rs"));
assert!(!matches("*.rs", "src/main.txt"));
assert!(matches("?.txt", "a.txt"));
assert!(!matches("?.txt", "ab.txt"));
assert!(matches("*", "any/depth/at/all"));
assert!(matches("*a*b*", "xxaxxbxx"));
assert!(matches("src/main.rs", "src/main.rs"), "a name with no pattern in it matches itself");
assert!(!matches("main.rs", "src/main.rs"), "a bare name is not a path pattern");
assert!(matches("*", ""));
}
#[test]
fn a_search_that_runs_out_of_time_stops_with_what_it_found_and_says_so() {
let scratch = Scratch::new("search-time");
scratch.write("a.txt", "needle\n");
scratch.write("b.txt", "needle\n");
let workspace = scratch.workspace();
let whole = search_module::search(&workspace, "needle", "", None, false, &|| false, Duration::from_secs(60));
assert_eq!(whole, Ok("a.txt:1: needle\nb.txt:1: needle".to_owned()), "with time to spare the walk is whole");
let none = search_module::search(&workspace, "needle", "", None, false, &|| false, Duration::ZERO);
let text = none.expect("a stopped search is still an answer");
assert!(!text.contains("a.txt:1"), "no time, nothing searched: {text}");
assert!(text.contains("Stopped after 0 s"), "and it says it stopped, not that nothing is there: {text}");
assert!(!text.starts_with("No matches for `needle` in `.`"), "{text}");
}
#[test]
fn a_search_the_person_stops_ends_between_files() {
let scratch = Scratch::new("search-stop");
for name in ["a", "b", "c", "d"] {
scratch.write(&format!("{name}.txt"), "needle\n");
}
let asked = Cell::new(0);
let stop_soon = || {
asked.set(asked.get() + 1);
asked.get() > 3
};
let outcome = scratch.workspace().perform(&search("needle", ""), &stop_soon);
assert!(outcome.text.contains("Stopped by the person"), "{}", outcome.text);
assert!(!outcome.text.contains("d.txt"), "the last file is never read: {}", outcome.text);
}