use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub const SUPPORTED_EXTENSIONS: &[&str] = &["txt", "md", "html", "htm", "pdf", "docx"];
#[derive(Debug, Clone, PartialEq)]
pub enum RagProgress {
Started { total: usize },
Indexing {
index: usize,
total: usize,
name: String,
dir: String,
chunks_done: usize,
chunks_total: usize,
},
Finished {
files: usize,
chunks: usize,
errors: usize,
cancelled: bool,
},
Reembedded {
rows: usize,
errors: usize,
cancelled: bool,
},
Removed { chunks: usize },
Listed {
sources: Vec<crate::entities::rag::RagSourceInfo>,
},
Failed(String),
}
pub fn is_supported(path: &Path) -> bool {
path.extension()
.and_then(OsStr::to_str)
.is_some_and(|e| SUPPORTED_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
}
fn has_ext(path: &Path, ext: &str) -> bool {
path.extension()
.and_then(OsStr::to_str)
.is_some_and(|e| e.eq_ignore_ascii_case(ext))
}
pub fn is_html(path: &Path) -> bool {
has_ext(path, "html") || has_ext(path, "htm")
}
pub fn is_pdf(path: &Path) -> bool {
has_ext(path, "pdf")
}
pub fn is_docx(path: &Path) -> bool {
has_ext(path, "docx")
}
pub fn scan(root: &Path, recursive: bool) -> std::io::Result<Vec<PathBuf>> {
let meta = std::fs::metadata(root)?;
let mut out = Vec::new();
if meta.is_file() {
if is_supported(root) {
out.push(root.to_path_buf());
}
return Ok(out);
}
collect_dir(root, recursive, &mut out);
out.sort();
Ok(out)
}
fn collect_dir(dir: &Path, recursive: bool, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
let path = entry.path();
if ft.is_dir() {
if recursive {
collect_dir(&path, recursive, out);
}
} else if ft.is_file() && is_supported(&path) {
out.push(path);
}
}
}
pub fn canonical_source(path: &Path) -> String {
match std::fs::canonicalize(path) {
Ok(abs) => strip_verbatim(abs.to_string_lossy().into_owned()),
Err(_) => path.to_string_lossy().into_owned(),
}
}
fn strip_verbatim(s: String) -> String {
match s.strip_prefix(r"\\?\") {
Some(rest) => rest.to_string(),
None => s,
}
}
pub fn read_text(
path: &Path,
hint: Option<&str>,
) -> std::io::Result<crate::shared::text_decode::FileText> {
let bytes = std::fs::read(path)?;
crate::shared::text_decode::decode_file(&bytes, is_html(path), hint).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"the file holds binary data, not text",
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn is_supported_matches_txt_and_md_case_insensitively() {
assert!(is_supported(Path::new("a.txt")));
assert!(is_supported(Path::new("a.MD")));
assert!(is_supported(Path::new("dir/b.Md")));
assert!(!is_supported(Path::new("a.rtf")));
assert!(!is_supported(Path::new("noext")));
}
#[test]
fn is_supported_and_helpers_match_pdf_and_docx_case_insensitively() {
assert!(is_supported(Path::new("doc.pdf")));
assert!(is_supported(Path::new("dir/report.DOCX")));
assert!(is_supported(Path::new("a.PDF")));
assert!(is_pdf(Path::new("doc.pdf")));
assert!(is_pdf(Path::new("dir/doc.PDF")));
assert!(is_docx(Path::new("report.docx")));
assert!(is_docx(Path::new("dir/report.DocX")));
assert!(!is_pdf(Path::new("report.docx")));
assert!(!is_pdf(Path::new("a.txt")));
assert!(!is_docx(Path::new("doc.pdf")));
assert!(!is_docx(Path::new("a.md")));
assert!(!is_pdf(Path::new("noext")));
assert!(!is_supported(Path::new("old.doc")));
assert!(!is_docx(Path::new("old.doc")));
}
#[test]
fn is_supported_and_is_html_match_html_case_insensitively() {
assert!(is_supported(Path::new("page.html")));
assert!(is_supported(Path::new("page.htm")));
assert!(is_supported(Path::new("page.HTML")));
assert!(is_html(Path::new("page.html")));
assert!(is_html(Path::new("dir/page.Htm")));
assert!(is_html(Path::new("page.HTML")));
assert!(!is_html(Path::new("a.txt")));
assert!(!is_html(Path::new("a.md")));
assert!(!is_html(Path::new("noext")));
}
#[test]
fn scan_single_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("note.md");
write(&file, "hello");
let found = scan(&file, false).unwrap();
assert_eq!(found, vec![file]);
let other = dir.path().join("data.bin");
write(&other, "x");
assert!(scan(&other, false).unwrap().is_empty());
}
#[test]
fn scan_directory_non_recursive_skips_subdirs_and_other_exts() {
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join("a.txt"), "a");
write(&dir.path().join("b.md"), "b");
write(&dir.path().join("c.rtf"), "c"); write(&dir.path().join("sub/d.txt"), "d");
let found = scan(dir.path(), false).unwrap();
let names: Vec<_> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["a.txt", "b.md"]);
}
#[test]
fn scan_directory_recursive_includes_subdirs() {
let dir = tempfile::tempdir().unwrap();
write(&dir.path().join("a.txt"), "a");
write(&dir.path().join("sub/d.txt"), "d");
write(&dir.path().join("sub/deep/e.md"), "e");
let found = scan(dir.path(), true).unwrap();
assert_eq!(found.len(), 3, "{found:?}");
}
#[test]
fn scan_missing_path_errors() {
let dir = tempfile::tempdir().unwrap();
assert!(scan(&dir.path().join("nope"), false).is_err());
}
#[test]
fn read_text_strips_bom() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("bom.txt");
write(&file, "\u{feff}содержимое");
assert_eq!(read_text(&file, None).unwrap().text, "содержимое");
}
#[test]
fn read_text_reads_a_file_in_its_own_encoding() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("report.txt");
let prose = "Выручка за март составила сто двадцать тысяч, за апрель немного больше.";
fs::write(&file, encoding_rs::WINDOWS_1251.encode(prose).0).unwrap();
let read = read_text(&file, Some("ru")).unwrap();
assert_eq!(
(read.text.as_str(), read.encoding.name()),
(prose, "windows-1251")
);
fs::write(&file, [0x00, 0x01, 0x02]).unwrap();
assert!(
read_text(&file, None).is_err(),
"a binary file is still refused"
);
}
#[test]
fn canonical_source_has_no_verbatim_prefix_and_is_stable() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("doc.txt");
write(&file, "x");
let src = canonical_source(&file);
assert!(!src.starts_with(r"\\?\"), "{src}");
assert_eq!(src, canonical_source(&file));
let missing = dir.path().join("nope.txt");
assert_eq!(canonical_source(&missing), missing.to_string_lossy());
}
}