use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
#[derive(Debug)]
pub(super) struct ProjectFiles {
names: HashSet<String>,
}
impl ProjectFiles {
pub(super) fn read(project_root: &Path) -> Option<Self> {
let entries = std::fs::read_dir(project_root).ok()?;
let names = entries
.filter_map(Result::ok)
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
Some(Self { names })
}
pub(super) fn contains_exact(&self, target: &str) -> bool {
self.names.contains(target)
}
pub(super) fn contains_prefix(&self, prefix: &str) -> bool {
self.names
.iter()
.any(|name| super::rules::matches_config_name_prefix(name, prefix))
}
pub(super) fn any_exact(&self, targets: &[&str]) -> bool {
targets.iter().any(|target| self.contains_exact(target))
}
pub(super) fn contains_extension(&self, target_extension: &str) -> bool {
self.names.iter().any(|name| {
Path::new(name)
.extension()
.and_then(OsStr::to_str)
.is_some_and(|extension| extension == target_extension)
})
}
pub(super) fn contains_path(&self, project_root: &Path, relative: &str) -> bool {
if relative.contains('/') {
project_root.join(relative).is_file()
} else {
self.contains_exact(relative)
}
}
pub(super) fn read_text(&self, project_root: &Path, file_name: &str) -> Option<String> {
if !self.contains_exact(file_name) {
return None;
}
read_regular_file_prefix(&project_root.join(file_name)).map(decode_text)
}
}
const MAX_SCAN_BYTES: u64 = 64 * 1024;
fn read_regular_file_prefix(path: &Path) -> Option<Vec<u8>> {
if !std::fs::metadata(path).ok()?.is_file() {
return None;
}
let file = open_for_scan(path).ok()?;
if !file.metadata().ok()?.is_file() {
return None;
}
let mut bytes = Vec::new();
file.take(MAX_SCAN_BYTES).read_to_end(&mut bytes).ok()?;
Some(bytes)
}
#[cfg(unix)]
fn open_for_scan(path: &Path) -> io::Result<File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(path)
}
#[cfg(not(unix))]
fn open_for_scan(path: &Path) -> io::Result<File> {
File::open(path)
}
fn decode_text(bytes: Vec<u8>) -> String {
match bytes.as_slice() {
[0xFF, 0xFE, rest @ ..] => decode_utf16(rest, u16::from_le_bytes),
[0xFE, 0xFF, rest @ ..] => decode_utf16(rest, u16::from_be_bytes),
_ => String::from_utf8(bytes)
.unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned()),
}
}
fn decode_utf16(bytes: &[u8], unit_from_bytes: fn([u8; 2]) -> u16) -> String {
let (pairs, _odd_trailing_byte) = bytes.as_chunks::<2>();
let units = pairs.iter().copied().map(unit_from_bytes);
char::decode_utf16(units)
.map(|unit| unit.unwrap_or(char::REPLACEMENT_CHARACTER))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn utf16_with_bom(text: &str, bom: [u8; 2], to_bytes: fn(u16) -> [u8; 2]) -> Vec<u8> {
bom.into_iter()
.chain(text.encode_utf16().flat_map(to_bytes))
.collect()
}
#[test]
fn decode_text_reads_utf16_with_byte_order_mark() {
let little = utf16_with_bom("Flask==3.0\r\n", [0xFF, 0xFE], u16::to_le_bytes);
assert_eq!(decode_text(little), "Flask==3.0\r\n");
let big = utf16_with_bom("fastapi\n", [0xFE, 0xFF], u16::to_be_bytes);
assert_eq!(decode_text(big), "fastapi\n");
}
#[test]
fn decode_text_replaces_invalid_utf8_instead_of_failing() {
assert_eq!(decode_text(b"caf\xe9 flask".to_vec()), "caf\u{fffd} flask");
assert_eq!(
decode_text(vec![b'a', 0xC3]),
"a\u{fffd}",
"a character split at the read cap"
);
assert_eq!(decode_text(b"plain".to_vec()), "plain");
}
#[cfg(unix)]
fn make_fifo(path: &Path) {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let c_path = CString::new(path.as_os_str().as_bytes()).expect("path has no nul byte");
let status = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
assert_eq!(status, 0, "mkfifo {}", path.display());
}
#[cfg(unix)]
#[test]
fn fifos_with_project_file_names_are_skipped_without_blocking() {
use std::sync::mpsc;
use std::time::Duration;
let dir = tempfile::TempDir::new().expect("temp dir");
make_fifo(&dir.path().join("app.py"));
make_fifo(&dir.path().join("requirements.txt"));
let root = dir.path().to_path_buf();
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let files = ProjectFiles::read(&root).expect("read project dir");
let text = files.read_text(&root, "app.py");
let label = crate::detect_from_config(&root);
sender.send((text, label)).expect("receiver is waiting");
});
let (text, label) = receiver
.recv_timeout(Duration::from_secs(10))
.expect("reading a FIFO must not block");
assert_eq!(text, None, "a FIFO is not a regular file");
assert_eq!(label.expect("Python markers are still listed"), "Python");
}
}