use std::path::Path;
use ignore::WalkBuilder;
use crate::domain::file_entry::FileEntry;
const MAX_DEPTH: usize = 10;
pub fn list_files(root: &Path) -> Vec<FileEntry> {
list_files_with_limits(root, Some(MAX_DEPTH), None)
}
pub fn list_files_for_explorer(
root: &Path,
max_depth: Option<usize>,
max_entries: Option<usize>,
) -> Vec<FileEntry> {
list_files_with_limits(root, max_depth, max_entries)
}
fn list_files_with_limits(
root: &Path,
max_depth: Option<usize>,
max_entries: Option<usize>,
) -> Vec<FileEntry> {
let walker = WalkBuilder::new(root)
.max_depth(max_depth)
.hidden(false)
.build();
let mut entries: Vec<FileEntry> = walker
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_type()
.is_some_and(|ft| ft.is_file() || ft.is_dir())
})
.filter_map(|entry| {
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
entry.path().strip_prefix(root).ok().and_then(|relative| {
let path = relative.to_string_lossy().to_string();
if path.is_empty() {
return None;
}
Some(FileEntry { is_dir, path })
})
})
.collect();
sort_and_limit_entries(&mut entries, max_entries);
entries
}
fn sort_and_limit_entries(entries: &mut Vec<FileEntry>, max_entries: Option<usize>) {
entries.sort_by(|first, second| {
second
.is_dir
.cmp(&first.is_dir)
.then(first.path.cmp(&second.path))
});
if let Some(max_entries) = max_entries {
entries.truncate(max_entries);
}
}
pub fn filter_entries<'a>(entries: &'a [FileEntry], query: &str) -> Vec<&'a FileEntry> {
if query.is_empty() {
return entries.iter().collect();
}
let query_lower = query.to_lowercase();
let query_chars: Vec<char> = query_lower.chars().collect();
let mut scored: Vec<ScoredEntry<'_>> = entries
.iter()
.filter_map(|entry| {
fuzzy_score_for_entry(entry, &query_chars, &query_lower).map(|score| ScoredEntry {
depth: path_depth(&entry.path),
entry,
path_len: entry.path.len(),
score,
})
})
.collect();
scored.sort_by(compare_scored_entries);
let mut filtered: Vec<&FileEntry> = scored.into_iter().map(|entry| entry.entry).collect();
prioritize_directories_for_trailing_slash(&mut filtered, query);
filtered
}
struct ScoredEntry<'a> {
entry: &'a FileEntry,
depth: usize,
path_len: usize,
score: i32,
}
fn compare_scored_entries(first: &ScoredEntry<'_>, second: &ScoredEntry<'_>) -> std::cmp::Ordering {
second
.score
.cmp(&first.score)
.then_with(|| first.depth.cmp(&second.depth))
.then_with(|| first.path_len.cmp(&second.path_len))
.then(first.entry.path.cmp(&second.entry.path))
}
fn path_depth(path: &str) -> usize {
path.bytes().filter(|&byte| byte == b'/').count()
}
fn fuzzy_score_for_entry(
entry: &FileEntry,
query_chars: &[char],
query_lower: &str,
) -> Option<i32> {
fuzzy_score(&entry.path, query_chars, query_lower, entry.is_dir)
}
fn prioritize_directories_for_trailing_slash(entries: &mut Vec<&FileEntry>, query: &str) {
if !query.ends_with('/') {
return;
}
entries.sort_by_key(|entry| !entry.is_dir);
}
fn fuzzy_score(
path: &str,
query_chars: &[char],
query_lower: &str,
append_trailing_slash: bool,
) -> Option<i32> {
let mut score: i32 = 0;
let mut query_index = 0;
let mut prev_matched = false;
let mut prev_path_char = None;
for path_char_orig in path.chars().chain(append_trailing_slash.then_some('/')) {
if query_index >= query_chars.len() {
break;
}
let mut matched = false;
for path_char in path_char_orig.to_lowercase() {
if query_index >= query_chars.len() {
break;
}
if path_char == query_chars[query_index] {
score += 1;
if prev_matched {
score += 3;
}
if prev_path_char.is_none_or(|previous_path_char| {
matches!(previous_path_char, '/' | '.' | '_' | '-')
}) {
score += 5;
}
query_index += 1;
matched = true;
prev_matched = true;
} else {
matched = false;
prev_matched = false;
}
}
if !matched {
prev_matched = false;
}
prev_path_char = Some(path_char_orig);
}
if query_index == query_chars.len() {
Some(score + basename_match_bonus(path, query_lower))
} else {
None
}
}
fn basename_match_bonus(path: &str, query: &str) -> i32 {
if query.is_empty() || query.contains('/') {
return 0;
}
let normalized_path = path.trim_end_matches('/');
let basename = normalized_path
.rsplit('/')
.next()
.unwrap_or(normalized_path);
let basename_lower = basename.to_lowercase();
let basename_stem = basename_lower.split('.').next().unwrap_or("");
if basename_stem == query {
return 60;
}
if basename_lower.starts_with(query) {
return 45;
}
if basename_lower.contains(query) {
return 30;
}
0
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::process::Command;
use std::{fs, io};
use tempfile::TempDir;
use super::*;
const TEST_MAX_ENTRIES: usize = 500;
#[cfg_attr(test, mockall::automock)]
trait GitFileIndexClient: Send + Sync {
fn init_repository(&self, repo_root: &Path) -> io::Result<()>;
}
struct RealGitFileIndexClient;
impl RealGitFileIndexClient {
fn git_init_failed_error(output: &std::process::Output) -> io::Error {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return io::Error::other("`git init -q` failed");
}
io::Error::other(format!("`git init -q` failed: {stderr}"))
}
}
impl GitFileIndexClient for RealGitFileIndexClient {
fn init_repository(&self, repo_root: &Path) -> io::Result<()> {
let output = Command::new("git")
.args(["init", "-q"])
.current_dir(repo_root)
.output()?;
if !output.status.success() {
return Err(Self::git_init_failed_error(&output));
}
Ok(())
}
}
fn initialize_test_repository(repo_root: &Path) {
let git_file_index_client = RealGitFileIndexClient;
git_file_index_client
.init_repository(repo_root)
.expect("test expectation should hold");
}
#[test]
fn test_list_files_empty_directory() {
let temp_dir = TempDir::new().expect("test expectation should hold");
let entries = list_files(temp_dir.path());
assert!(entries.is_empty());
}
#[test]
fn test_list_files_returns_sorted_entries() {
let temp_dir = TempDir::new().expect("test expectation should hold");
fs::write(temp_dir.path().join("banana.txt"), "").expect("test expectation should hold");
fs::write(temp_dir.path().join("apple.txt"), "").expect("test expectation should hold");
fs::write(temp_dir.path().join("cherry.txt"), "").expect("test expectation should hold");
let entries = list_files(temp_dir.path());
let paths: Vec<&str> = entries.iter().map(|entry| entry.path.as_str()).collect();
assert_eq!(paths, vec!["apple.txt", "banana.txt", "cherry.txt"]);
}
#[test]
fn test_list_files_returns_relative_paths() {
let temp_dir = TempDir::new().expect("test expectation should hold");
fs::create_dir_all(temp_dir.path().join("src")).expect("test expectation should hold");
fs::write(temp_dir.path().join("src/main.rs"), "").expect("test expectation should hold");
let entries = list_files(temp_dir.path());
let file_entries: Vec<_> = entries.iter().filter(|entry| !entry.is_dir).collect();
assert_eq!(file_entries.len(), 1);
assert_eq!(file_entries[0].path, "src/main.rs");
}
#[test]
fn test_list_files_respects_gitignore() {
let temp_dir = TempDir::new().expect("test expectation should hold");
initialize_test_repository(temp_dir.path());
fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n")
.expect("test expectation should hold");
fs::write(temp_dir.path().join("kept.txt"), "").expect("test expectation should hold");
fs::write(temp_dir.path().join("ignored.txt"), "").expect("test expectation should hold");
let entries = list_files(temp_dir.path());
let paths: Vec<&str> = entries.iter().map(|entry| entry.path.as_str()).collect();
assert!(paths.contains(&"kept.txt"));
assert!(!paths.contains(&"ignored.txt"));
}
#[test]
fn test_list_files_includes_non_ignored_dotfiles() {
let temp_dir = TempDir::new().expect("test expectation should hold");
initialize_test_repository(temp_dir.path());
fs::write(temp_dir.path().join(".gitignore"), ".ignored-dotfile\n")
.expect("test expectation should hold");
fs::write(temp_dir.path().join(".visible-dotfile"), "")
.expect("test expectation should hold");
fs::write(temp_dir.path().join(".ignored-dotfile"), "")
.expect("test expectation should hold");
let entries = list_files(temp_dir.path());
let paths: Vec<&str> = entries.iter().map(|entry| entry.path.as_str()).collect();
assert!(paths.contains(&".visible-dotfile"));
assert!(!paths.contains(&".ignored-dotfile"));
}
#[test]
fn test_list_files_includes_directories() {
let temp_dir = TempDir::new().expect("test expectation should hold");
fs::create_dir_all(temp_dir.path().join("subdir")).expect("test expectation should hold");
fs::write(temp_dir.path().join("file.txt"), "").expect("test expectation should hold");
let entries = list_files(temp_dir.path());
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, "subdir");
assert!(entries[0].is_dir);
assert_eq!(entries[1].path, "file.txt");
assert!(!entries[1].is_dir);
}
#[test]
fn test_list_files_excludes_root_directory() {
let temp_dir = TempDir::new().expect("test expectation should hold");
fs::write(temp_dir.path().join("file.txt"), "").expect("test expectation should hold");
let entries = list_files(temp_dir.path());
assert!(!entries.iter().any(|entry| entry.path.is_empty()));
}
#[test]
fn test_list_files_sorts_directories_before_files() {
let temp_dir = TempDir::new().expect("test expectation should hold");
fs::write(temp_dir.path().join("aaa_file.txt"), "").expect("test expectation should hold");
fs::create_dir_all(temp_dir.path().join("zzz_dir")).expect("test expectation should hold");
let entries = list_files(temp_dir.path());
assert_eq!(entries[0].path, "zzz_dir");
assert!(entries[0].is_dir);
assert_eq!(entries[1].path, "aaa_file.txt");
assert!(!entries[1].is_dir);
}
#[test]
fn test_list_files_is_unbounded_within_max_depth() {
let temp_dir = TempDir::new().expect("test expectation should hold");
for index in 0..TEST_MAX_ENTRIES + 50 {
fs::write(temp_dir.path().join(format!("file_{index:04}.txt")), "")
.expect("test expectation should hold");
}
let entries = list_files(temp_dir.path());
assert_eq!(entries.len(), TEST_MAX_ENTRIES + 50);
}
#[test]
fn test_list_files_keeps_files_when_many_directories_exist() {
let temp_dir = TempDir::new().expect("test expectation should hold");
for index in 0..TEST_MAX_ENTRIES + 50 {
fs::create_dir_all(temp_dir.path().join(format!("dir_{index:04}")))
.expect("test expectation should hold");
}
fs::write(temp_dir.path().join("z_last_file.rs"), "")
.expect("test expectation should hold");
let entries = list_files(temp_dir.path());
assert!(entries.iter().any(|entry| entry.path == "z_last_file.rs"));
assert!(entries.len() > TEST_MAX_ENTRIES);
}
#[test]
fn test_list_files_for_explorer_can_be_unbounded() {
let temp_dir = TempDir::new().expect("test expectation should hold");
for index in 0..TEST_MAX_ENTRIES + 50 {
fs::write(temp_dir.path().join(format!("file_{index:04}.txt")), "")
.expect("test expectation should hold");
}
let entries = list_files_for_explorer(temp_dir.path(), None, None);
assert_eq!(entries.len(), TEST_MAX_ENTRIES + 50);
}
#[test]
fn test_list_files_for_explorer_respects_custom_limits() {
let temp_dir = TempDir::new().expect("test expectation should hold");
for index in 0..20 {
fs::write(temp_dir.path().join(format!("file_{index:04}.txt")), "")
.expect("test expectation should hold");
}
let entries = list_files_for_explorer(temp_dir.path(), Some(3), Some(7));
assert_eq!(entries.len(), 7);
}
#[test]
fn test_sort_and_limit_entries_truncates_after_sort() {
let mut entries: Vec<FileEntry> = (0..TEST_MAX_ENTRIES + 20)
.rev()
.map(|index| FileEntry {
is_dir: false,
path: format!("file_{index:04}.txt"),
})
.collect();
sort_and_limit_entries(&mut entries, Some(TEST_MAX_ENTRIES));
assert_eq!(entries.len(), TEST_MAX_ENTRIES);
assert_eq!(
entries.first().map(|entry| entry.path.as_str()),
Some("file_0000.txt")
);
assert_eq!(
entries.last().map(|entry| entry.path.as_str()),
Some("file_0499.txt")
);
}
#[test]
fn test_list_files_respects_max_depth() {
let temp_dir = TempDir::new().expect("test expectation should hold");
let mut deep_path = temp_dir.path().to_path_buf();
for level in 0..MAX_DEPTH + 2 {
deep_path = deep_path.join(format!("d{level}"));
}
fs::create_dir_all(&deep_path).expect("test expectation should hold");
fs::write(deep_path.join("deep.txt"), "").expect("test expectation should hold");
fs::write(temp_dir.path().join("shallow.txt"), "").expect("test expectation should hold");
let entries = list_files(temp_dir.path());
let paths: Vec<&str> = entries.iter().map(|entry| entry.path.as_str()).collect();
assert!(paths.contains(&"shallow.txt"));
assert!(!paths.iter().any(|path| path.contains("deep.txt")));
}
#[test]
fn test_filter_entries_case_insensitive() {
let entries = vec![
FileEntry {
is_dir: false,
path: "src/Main.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "README.md".to_string(),
},
];
let filtered = filter_entries(&entries, "main");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, "src/Main.rs");
}
#[test]
fn test_filter_entries_empty_query_returns_all() {
let entries = vec![
FileEntry {
is_dir: false,
path: "a.txt".to_string(),
},
FileEntry {
is_dir: false,
path: "b.txt".to_string(),
},
];
let filtered = filter_entries(&entries, "");
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filter_entries_no_match() {
let entries = vec![FileEntry {
is_dir: false,
path: "hello.txt".to_string(),
}];
let filtered = filter_entries(&entries, "xyz");
assert!(filtered.is_empty());
}
#[test]
fn test_filter_entries_fuzzy_match() {
let entries = vec![
FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "src/model.rs".to_string(),
},
];
let filtered = filter_entries(&entries, "smr");
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filter_entries_fuzzy_ranks_consecutive_higher() {
let entries = vec![
FileEntry {
is_dir: false,
path: "src/xmxaxixn.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
},
];
let filtered = filter_entries(&entries, "main");
assert_eq!(filtered[0].path, "src/main.rs");
}
#[test]
fn test_filter_entries_prioritizes_basename_match() {
let entries = vec![
FileEntry {
is_dir: false,
path: "crates/agentty/src/infra/git.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "crates/agentty/src/app/setting.rs".to_string(),
},
];
let filtered = filter_entries(&entries, "setting");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].path, "crates/agentty/src/app/setting.rs");
}
#[test]
fn test_filter_entries_exact_basename_prefers_shallower_path() {
let entries = vec![
FileEntry {
is_dir: false,
path: ".codex/AGENTS.md".to_string(),
},
FileEntry {
is_dir: false,
path: "docs/AGENTS.md".to_string(),
},
FileEntry {
is_dir: false,
path: "AGENTS.md".to_string(),
},
];
let filtered = filter_entries(&entries, "agents.md");
assert_eq!(filtered.len(), 3);
assert_eq!(filtered[0].path, "AGENTS.md");
}
#[test]
fn test_filter_entries_fuzzy_ranks_segment_start_higher() {
let entries = vec![
FileEntry {
is_dir: false,
path: "docs/domain.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "src/db.rs".to_string(),
},
];
let filtered = filter_entries(&entries, "d");
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_filter_entries_fuzzy_no_match_wrong_order() {
let entries = vec![FileEntry {
is_dir: false,
path: "abc.txt".to_string(),
}];
let filtered = filter_entries(&entries, "cb");
assert!(filtered.is_empty());
}
#[test]
fn test_filter_entries_matches_path_segments() {
let entries = vec![
FileEntry {
is_dir: false,
path: "src/app/session.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "tests/unit.rs".to_string(),
},
];
let filtered = filter_entries(&entries, "app/session");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].path, "src/app/session.rs");
}
#[test]
fn test_filter_entries_trailing_slash_prioritizes_directories() {
let entries = vec![
FileEntry {
is_dir: false,
path: "src/aaa.rs".to_string(),
},
FileEntry {
is_dir: true,
path: "src/zzz".to_string(),
},
];
let filtered = filter_entries(&entries, "src/");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].path, "src/zzz");
assert!(filtered[0].is_dir);
assert_eq!(filtered[1].path, "src/aaa.rs");
assert!(!filtered[1].is_dir);
}
#[test]
fn test_filter_entries_trailing_slash_matches_exact_directory() {
let entries = vec![
FileEntry {
is_dir: true,
path: "src".to_string(),
},
FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
},
];
let filtered = filter_entries(&entries, "src/");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].path, "src");
assert!(filtered[0].is_dir);
assert_eq!(filtered[1].path, "src/main.rs");
assert!(!filtered[1].is_dir);
}
#[test]
fn test_fuzzy_score_returns_none_for_no_match() {
let result = fuzzy_score("hello.txt", &['x', 'y', 'z'], "xyz", false);
assert!(result.is_none());
}
#[test]
fn test_fuzzy_score_returns_some_for_match() {
let result = fuzzy_score("src/main.rs", &['m', 'a', 'i', 'n'], "main", false);
assert!(result.is_some());
}
#[test]
fn test_fuzzy_score_consecutive_beats_scattered() {
let consecutive = fuzzy_score("main.rs", &['m', 'a', 'i', 'n'], "main", false);
let scattered = fuzzy_score(
"my_archive_index_name.rs",
&['m', 'a', 'i', 'n'],
"main",
false,
);
assert!(
consecutive.expect("test expectation should hold")
> scattered.expect("test expectation should hold")
);
}
#[test]
fn test_fuzzy_score_directory_trailing_slash_matches_without_allocation() {
let result = fuzzy_score("src", &['s', 'r', 'c', '/'], "src/", true);
assert!(result.is_some());
}
}