use anyhow::Result;
use std::path::{Path, PathBuf};
use tui_tree_widget::TreeItem;
use walkdir::WalkDir;
const FILE_EXTS: [&str; 1] = ["txt"];
#[derive(Debug)]
pub struct NovelFileIndex {
entries: Vec<NovelFileEntry>,
}
#[derive(Debug)]
struct NovelFileEntry {
path: PathBuf,
name: String,
name_lower: String,
kind: EntryKind,
}
#[derive(Debug)]
enum EntryKind {
File,
Directory(Vec<NovelFileEntry>),
}
impl NovelFileIndex {
pub fn from_path(path: PathBuf) -> Result<Self> {
let path = if path.is_relative() {
std::env::current_dir()?.join(path)
} else {
path
};
let entries = if path.is_file() {
if !has_supported_ext(&path) {
return Err(anyhow::anyhow!("不支持的文件类型"));
}
vec![NovelFileEntry::new(path, EntryKind::File)]
} else {
scan(&path)?
};
Ok(Self { entries })
}
pub fn filter(&self, query: &str) -> Vec<TreeItem<'static, PathBuf>> {
let query = query.trim().to_lowercase();
build_tree(&self.entries, &query)
}
}
impl NovelFileEntry {
fn new(path: PathBuf, kind: EntryKind) -> Self {
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
Self {
name_lower: name.to_lowercase(),
name,
path,
kind,
}
}
}
fn scan(dir: &Path) -> Result<Vec<NovelFileEntry>> {
let mut entries = Vec::new();
let walker = WalkDir::new(dir)
.min_depth(1)
.max_depth(1)
.sort_by(|a, b| {
let a_is_dir = a.file_type().is_dir();
let b_is_dir = b.file_type().is_dir();
b_is_dir.cmp(&a_is_dir).then_with(|| a.path().cmp(b.path()))
});
for entry in walker {
let entry = entry?;
let is_dir = entry.file_type().is_dir();
let path = entry.into_path();
if is_dir {
let children = scan(&path)?;
if children.is_empty() {
continue;
}
entries.push(NovelFileEntry::new(path, EntryKind::Directory(children)));
} else if has_supported_ext(&path) {
entries.push(NovelFileEntry::new(path, EntryKind::File));
}
}
Ok(entries)
}
fn build_tree(entries: &[NovelFileEntry], query: &str) -> Vec<TreeItem<'static, PathBuf>> {
let mut items = Vec::new();
for entry in entries {
match &entry.kind {
EntryKind::Directory(children) => {
let children = build_tree(children, query);
if children.is_empty() {
continue;
}
match TreeItem::new(entry.path.clone(), entry.name.clone(), children) {
Ok(item) => items.push(item),
Err(error) => debug_assert!(false, "同级路径重复: {error}"),
}
}
EntryKind::File if entry.name_lower.contains(query) => {
items.push(TreeItem::new_leaf(entry.path.clone(), entry.name.clone()));
}
EntryKind::File => {}
}
}
items
}
fn has_supported_ext(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
FILE_EXTS
.iter()
.any(|supported| ext.eq_ignore_ascii_case(supported))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(tag: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("trnovel-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("创建测试目录");
root
}
fn leaf_paths(items: &[TreeItem<'static, PathBuf>]) -> Vec<PathBuf> {
items
.iter()
.flat_map(|item| {
if item.children().is_empty() {
vec![item.identifier().clone()]
} else {
leaf_paths(item.children())
}
})
.collect()
}
fn top_level_names(items: &[TreeItem<'static, PathBuf>]) -> Vec<String> {
items
.iter()
.map(|item| {
item.identifier()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect()
}
#[test]
fn filtering_reuses_the_scanned_index() {
let root = temp_dir("file-index");
let nested = root.join("nested");
fs::create_dir_all(&nested).expect("创建子目录");
fs::write(root.join("alpha.txt"), "alpha").expect("创建 alpha");
fs::write(root.join("ignored.md"), "ignored").expect("创建 md");
fs::write(nested.join("beta.txt"), "beta").expect("创建 beta");
let index = NovelFileIndex::from_path(root.clone()).expect("建立文件索引");
fs::remove_file(root.join("alpha.txt")).expect("删除 alpha");
fs::remove_file(nested.join("beta.txt")).expect("删除 beta");
assert_eq!(
leaf_paths(&index.filter("alpha")),
vec![root.join("alpha.txt")]
);
assert_eq!(
leaf_paths(&index.filter("beta")),
vec![nested.join("beta.txt")]
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn directories_without_matches_are_dropped() {
let root = temp_dir("empty-dir");
let nested = root.join("nested");
fs::create_dir_all(&nested).expect("创建子目录");
fs::write(root.join("alpha.txt"), "alpha").expect("创建 alpha");
fs::write(nested.join("beta.txt"), "beta").expect("创建 beta");
let index = NovelFileIndex::from_path(root.clone()).expect("建立文件索引");
assert_eq!(top_level_names(&index.filter("alpha")), vec!["alpha.txt"]);
assert_eq!(
top_level_names(&index.filter("")),
vec!["nested", "alpha.txt"]
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn matching_ignores_case_for_both_name_and_extension() {
let root = temp_dir("case");
fs::write(root.join("Example.TXT"), "example").expect("创建 Example.TXT");
fs::write(root.join("other.txt"), "other").expect("创建 other.txt");
let index = NovelFileIndex::from_path(root.clone()).expect("建立文件索引");
assert_eq!(index.filter("").len(), 2);
assert_eq!(
top_level_names(&index.filter("example")),
vec!["Example.TXT"]
);
assert_eq!(
top_level_names(&index.filter("EXAMPLE")),
vec!["Example.TXT"]
);
assert_eq!(index.filter(" ").len(), 2);
assert!(index.filter("missing").is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn single_file_path_is_indexed_and_validated() {
let root = temp_dir("single-file");
let book = root.join("solo.txt");
fs::write(&book, "solo").expect("创建 solo.txt");
fs::write(root.join("note.md"), "note").expect("创建 note.md");
let index = NovelFileIndex::from_path(book.clone()).expect("建立文件索引");
assert_eq!(leaf_paths(&index.filter("")), vec![book]);
assert!(NovelFileIndex::from_path(root.join("note.md")).is_err());
let _ = fs::remove_dir_all(root);
}
}