use crate::{LogError, SourceLanguage};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::path::{Component, Path, PathBuf};
use std::time::SystemTime;
use std::{fs, io};
fn is_ignored_dir(name: &OsStr) -> bool {
name == ".git" || name == ".hg" || name == ".svn" || name == ".vscode"
}
fn build_gitignore(root: &Path) -> Gitignore {
let mut builder = GitignoreBuilder::new(root);
builder.add(root.join(".gitignore"));
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
fn is_gitignored(gi: &Gitignore, path: &Path, is_dir: bool) -> bool {
gi.matched_path_or_any_parents(path, is_dir).is_ignore()
}
enum ShallowCheckResult {
File {
latest_modified_time: SystemTime,
},
Directory {
latest_entries: BTreeMap<OsString, Result<fs::Metadata, io::Error>>,
},
Error,
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize, Hash, Eq, PartialEq)]
pub struct SourceFileID(usize);
#[derive(Copy, Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct SourceFileInfo {
pub language: SourceLanguage,
pub id: SourceFileID,
}
impl SourceFileInfo {
thread_local! {
static NEXT_ID: RefCell<usize> = const { RefCell::new(0) };
}
pub fn new(language: SourceLanguage) -> Self {
Self::NEXT_ID.with(|next_id| {
let mut inner = next_id.borrow_mut();
let id = SourceFileID(*inner);
*inner += 1;
Self { language, id }
})
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum SourceHierContent {
File {
info: SourceFileInfo,
last_modified_time: SystemTime,
},
UnsupportedFile {},
Directory {
entries: BTreeMap<OsString, SourceHierNode>,
},
Error {
#[serde(skip)]
source: LogError,
},
Unknown {},
}
impl SourceHierContent {
fn entries_of(
path: &Path,
) -> Result<BTreeMap<OsString, Result<fs::Metadata, io::Error>>, io::Error> {
Ok(fs::read_dir(path)?
.flat_map(|entry| match entry {
Ok(entry) => Some((entry.file_name(), entry.metadata())),
Err(_err) => None,
})
.collect())
}
fn from_dir(path: &Path, gi: &Gitignore) -> Self {
match Self::entries_of(path) {
Ok(entries) => Self::Directory {
entries: entries
.into_iter()
.filter(|entry| {
!is_ignored_dir(&entry.0) && {
let child_path = path.join(&entry.0);
let is_dir = entry.1.as_ref().map(|m| m.is_dir()).unwrap_or(false);
!is_gitignored(gi, &child_path, is_dir)
}
})
.map(|(entry_name, meta)| {
(
entry_name.to_os_string(),
SourceHierNode::from_int(&path.join(entry_name), meta, gi),
)
})
.collect(),
},
Err(err) => Self::Error {
source: LogError::CannotAccessPath {
path: path.to_path_buf(),
source: err.into(),
},
},
}
}
fn from(path: &Path, metadata: Result<fs::Metadata, io::Error>, gi: &Gitignore) -> Self {
match metadata {
Ok(meta) => {
if meta.is_dir() {
Self::from_dir(path, gi)
} else if meta.is_file() {
match SourceLanguage::from_path(path) {
Some(language) => match meta.modified() {
Ok(last_modified_time) => Self::File {
info: SourceFileInfo::new(language),
last_modified_time,
},
Err(err) => Self::Error {
source: LogError::CannotAccessPath {
path: path.to_path_buf(),
source: err.into(),
},
},
},
None => Self::UnsupportedFile {},
}
} else {
Self::Unknown {}
}
}
Err(err) => Self::Error {
source: LogError::CannotAccessPath {
path: path.to_path_buf(),
source: err.into(),
},
},
}
}
fn shallow_check(
path: &Path,
metadata: &Result<fs::Metadata, io::Error>,
) -> ShallowCheckResult {
match metadata {
Ok(meta) => {
if meta.is_file() {
match meta.modified() {
Ok(latest_modified_time) => ShallowCheckResult::File {
latest_modified_time,
},
Err(_) => ShallowCheckResult::Error,
}
} else if meta.is_dir() {
match Self::entries_of(path) {
Ok(latest_entries) => ShallowCheckResult::Directory { latest_entries },
Err(_) => ShallowCheckResult::Error,
}
} else {
ShallowCheckResult::Error
}
}
Err(_) => ShallowCheckResult::Error,
}
}
fn sync_int(
&mut self,
path: &Path,
latest_meta: Result<fs::Metadata, io::Error>,
deleted_events: &mut Vec<ScanEvent>,
gi: &Gitignore,
) -> bool {
let latest_content = Self::shallow_check(path, &latest_meta);
*self = match self {
SourceHierContent::File {
last_modified_time,
info,
..
} => match latest_content {
ShallowCheckResult::File {
latest_modified_time,
..
} if *last_modified_time == latest_modified_time => {
return false;
}
_ => {
deleted_events.push(ScanEvent::DeletedFile(PathBuf::from(path), info.id));
Self::from(path, latest_meta, gi)
}
},
SourceHierContent::Directory { ref mut entries } => match latest_content {
ShallowCheckResult::Directory { latest_entries } => {
let mut changed = false;
entries.retain(|name, node| {
let exists = latest_entries.contains_key(name);
if !exists {
node.deleted(path, name, deleted_events);
changed = true;
}
exists
});
let mut new_entries: Vec<(OsString, Result<fs::Metadata, io::Error>)> =
Vec::new();
for (name, meta) in latest_entries {
let child_path = path.join(&name);
let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
if is_ignored_dir(name.as_os_str())
|| is_gitignored(gi, &child_path, is_dir)
{
} else if let Some(existing_entry) = entries.get_mut(&name) {
existing_entry.sync(&child_path, meta, deleted_events, gi)
} else {
new_entries.push((name, meta));
changed = true;
}
}
new_entries.into_iter().for_each(|(name, meta)| {
let node = SourceHierNode::from_int(&path.join(&name), meta, gi);
entries.insert(name, node);
});
return changed;
}
_ => Self::from(path, latest_meta, gi),
},
_ => Self::from(path, latest_meta, gi),
};
true
}
pub fn find_file(
&self,
self_path: &Path,
desired_path: &Path,
accum: &mut Vec<(PathBuf, SourceFileInfo)>,
) {
match self {
SourceHierContent::File { info, .. } if desired_path == Path::new("") => {
accum.push((self_path.to_path_buf(), *info));
}
SourceHierContent::Directory { ref entries } => {
let mut components = desired_path.components();
if let Some(Component::Normal(name)) = components.next() {
if let Some(node) = entries.get(name) {
node.content
.find_file(&self_path.join(name), components.as_path(), accum);
} else {
for (name, entry) in entries {
let sub_path = self_path.join(name);
entry.content.find_file(&sub_path, desired_path, accum);
}
}
}
}
_ => {}
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SourceHierNode {
pub last_scan_time: Option<SystemTime>,
pub content: SourceHierContent,
}
impl SourceHierNode {
fn from_int(path: &Path, metadata: Result<fs::Metadata, io::Error>, gi: &Gitignore) -> Self {
match metadata {
Ok(meta) => {
if meta.is_dir() {
Self {
last_scan_time: None,
content: SourceHierContent::from_dir(path, gi),
}
} else if meta.is_file() {
match SourceLanguage::from_path(path) {
Some(language) => match meta.modified() {
Ok(last_modified_time) => Self {
last_scan_time: None,
content: SourceHierContent::File {
info: SourceFileInfo::new(language),
last_modified_time,
},
},
Err(err) => Self {
last_scan_time: None,
content: SourceHierContent::Error {
source: LogError::CannotAccessPath {
path: path.to_path_buf(),
source: err.into(),
},
},
},
},
None => Self {
last_scan_time: None,
content: SourceHierContent::UnsupportedFile {},
},
}
} else {
Self {
last_scan_time: None,
content: SourceHierContent::Unknown {},
}
}
}
Err(err) => Self {
last_scan_time: None,
content: SourceHierContent::Error {
source: LogError::CannotAccessPath {
path: path.to_path_buf(),
source: err.into(),
},
},
},
}
}
fn stub() -> Self {
SourceHierNode {
last_scan_time: None,
content: SourceHierContent::Unknown {},
}
}
fn deleted(&self, path: &Path, name: &OsStr, deleted_events: &mut Vec<ScanEvent>) {
match &self.content {
SourceHierContent::File { info, .. } => {
deleted_events.push(ScanEvent::DeletedFile(path.join(name), info.id))
}
SourceHierContent::UnsupportedFile { .. } => {}
SourceHierContent::Directory { entries } => {
let dir_path = path.join(name);
for (child_name, node) in entries {
node.deleted(&dir_path, child_name, deleted_events);
}
}
SourceHierContent::Error { .. } => {}
SourceHierContent::Unknown { .. } => {}
}
}
fn sync(
&mut self,
path: &Path,
meta: Result<fs::Metadata, io::Error>,
deleted_events: &mut Vec<ScanEvent>,
gi: &Gitignore,
) {
if self.content.sync_int(path, meta, deleted_events, gi) {
self.last_scan_time = None;
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ScanEvent {
NewFile(PathBuf, SourceFileInfo),
DeletedFile(PathBuf, SourceFileID),
}
struct TreeCursorMut<'a> {
curr_path: PathBuf,
curr_node: &'a mut SourceHierNode,
}
pub struct TreeScanner<'a> {
deleted_events: Vec<ScanEvent>,
stack: Vec<TreeCursorMut<'a>>,
}
impl Iterator for TreeScanner<'_> {
type Item = ScanEvent;
fn next(&mut self) -> Option<Self::Item> {
if let Some(event) = self.deleted_events.pop() {
return Some(event);
}
while let Some(cursor) = self.stack.pop() {
let last_scan_time = cursor.curr_node.last_scan_time;
cursor.curr_node.last_scan_time = Some(SystemTime::now());
match &mut cursor.curr_node.content {
SourceHierContent::File { info, .. } => match last_scan_time {
Some(_) => {}
_ => return Some(ScanEvent::NewFile(cursor.curr_path, *info)),
},
SourceHierContent::UnsupportedFile { .. } => {}
SourceHierContent::Directory { ref mut entries } => {
for child in entries.iter_mut() {
self.stack.push(TreeCursorMut {
curr_path: cursor.curr_path.join(child.0),
curr_node: child.1,
});
}
}
SourceHierContent::Error { .. } => {}
SourceHierContent::Unknown {} => {}
}
}
None
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct SourceHierStats {
pub files: usize,
pub unsupported_files: usize,
pub directories: usize,
pub errors: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SourceHierTree {
pub root_path: PathBuf,
pub root_node: SourceHierNode,
next_id: usize,
#[serde(skip)]
deleted_events: Vec<ScanEvent>,
stats: SourceHierStats,
}
impl SourceHierTree {
pub fn from(path: &Path) -> SourceHierTree {
SourceHierTree {
root_path: path.to_path_buf(),
root_node: SourceHierNode::stub(),
next_id: 0,
deleted_events: Vec::new(),
stats: SourceHierStats::default(),
}
}
pub fn sync(&mut self) {
let gi = build_gitignore(&self.root_path);
SourceFileInfo::NEXT_ID.with(|id_opt| {
*id_opt.borrow_mut() = self.next_id;
});
self.root_node.sync(
&self.root_path,
fs::metadata(&self.root_path),
&mut self.deleted_events,
&gi,
);
self.next_id = SourceFileInfo::NEXT_ID.with(|id_opt| *id_opt.borrow());
self.stats = self.compute_stats();
}
pub fn scan(&'_ mut self) -> TreeScanner<'_> {
let deleted_events = std::mem::take(&mut self.deleted_events);
TreeScanner {
deleted_events,
stack: vec![TreeCursorMut {
curr_path: self.root_path.clone(),
curr_node: &mut self.root_node,
}],
}
}
pub fn find_file(&self, path: &Path) -> Vec<(PathBuf, SourceFileInfo)> {
let path_to_find = if path.is_absolute() {
match path.strip_prefix(&self.root_path) {
Ok(sub_path) => sub_path,
Err(_) => return vec![],
}
} else {
path
};
let mut retval = vec![];
self.root_node
.content
.find_file(&self.root_path, path_to_find, &mut retval);
retval
}
pub fn visit<F>(&self, mut f: F)
where
F: FnMut(&SourceHierNode),
{
fn walk<F>(node: &SourceHierNode, f: &mut F)
where
F: FnMut(&SourceHierNode),
{
f(node);
if let SourceHierContent::Directory { entries } = &node.content {
for child in entries.values() {
walk(child, f);
}
}
}
walk(&self.root_node, &mut f);
}
fn compute_stats(&self) -> SourceHierStats {
let mut retval = SourceHierStats::default();
self.visit(|node| match node.content {
SourceHierContent::File { .. } => retval.files += 1,
SourceHierContent::UnsupportedFile { .. } => retval.unsupported_files += 1,
SourceHierContent::Directory { .. } => retval.directories += 1,
SourceHierContent::Error { .. } => retval.errors += 1,
SourceHierContent::Unknown { .. } => {}
});
retval
}
pub fn stats(&self) -> &SourceHierStats {
&self.stats
}
}
#[cfg(test)]
mod test {
use crate::source_hier::{ScanEvent, SourceFileID, SourceFileInfo, SourceHierTree};
use crate::SourceLanguage;
use fs_extra::dir::copy;
use fs_extra::dir::CopyOptions;
use insta::assert_yaml_snapshot;
use std::fs;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use tempfile::{tempdir, TempDir};
fn setup_test_environment(source_dir: &Path) -> TempDir {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let dest_path = temp_dir.path().to_path_buf();
let mut options = CopyOptions::new();
options.overwrite = true; options.copy_inside = true;
copy(source_dir, &dest_path, &options)
.expect("Failed to copy source directory to temporary directory");
temp_dir
}
fn redact_event(event: ScanEvent) -> ScanEvent {
match event {
ScanEvent::NewFile(path, info) => {
ScanEvent::NewFile(path.file_name().unwrap().into(), info)
}
ScanEvent::DeletedFile(path, id) => {
ScanEvent::DeletedFile(path.file_name().unwrap().into(), id)
}
}
}
#[test]
fn test_with_resources_dir() {
let tests_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests");
let temp_test_dir = setup_test_environment(&tests_path);
fs::create_dir(temp_test_dir.path().join(".git")).unwrap();
let _ = File::create_new(temp_test_dir.path().join(".git/config"))
.unwrap()
.write("abc".as_bytes())
.unwrap();
let basic_path = temp_test_dir.path().join("tests/java/Basic.java");
{
let metadata = fs::metadata(&basic_path).unwrap();
let mut perms = metadata.permissions();
make_writable(&mut perms);
fs::set_permissions(&basic_path, perms).unwrap();
}
let mut tree = SourceHierTree::from(temp_test_dir.path());
tree.sync();
let events: Vec<ScanEvent> = tree.scan().map(redact_event).collect();
assert_yaml_snapshot!(events);
let find_res = tree.find_file(&basic_path);
assert_eq!(
find_res,
vec![(
basic_path.clone(),
SourceFileInfo {
language: SourceLanguage::Java,
id: SourceFileID(1)
}
)]
);
let no_events: Vec<ScanEvent> = tree.scan().map(redact_event).collect();
assert_yaml_snapshot!(no_events);
fs::remove_file(temp_test_dir.path().join("tests/test_java.rs")).unwrap();
let _ = File::create(temp_test_dir.path().join("new.rs"))
.unwrap()
.write("abc".as_bytes())
.unwrap();
let _ = File::options()
.append(true)
.open(&basic_path)
.unwrap()
.write("def".as_bytes())
.unwrap();
tree.sync();
let new_and_updated_events: Vec<ScanEvent> = tree.scan().map(redact_event).collect();
assert_yaml_snapshot!(new_and_updated_events);
fs::remove_dir_all(temp_test_dir.path().join("tests/java")).unwrap();
tree.sync();
let deleted_dir_events: Vec<ScanEvent> = tree.scan().map(redact_event).collect();
assert_yaml_snapshot!(deleted_dir_events);
}
#[cfg(unix)]
fn make_writable(perms: &mut std::fs::Permissions) {
use std::os::unix::fs::PermissionsExt;
let mode = perms.mode();
perms.set_mode(mode | 0o200);
}
#[cfg(not(unix))]
fn make_writable(perms: &mut std::fs::Permissions) {
perms.set_readonly(false);
}
#[test]
fn test_gitignore_filtering() {
let temp_dir = tempdir().expect("Failed to create temporary directory");
let root = temp_dir.path();
let mut gitignore = File::create(root.join(".gitignore")).unwrap();
writeln!(gitignore, "*.log").unwrap();
writeln!(gitignore, "build/").unwrap();
drop(gitignore);
fs::create_dir(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), b"fn main() {}").unwrap();
fs::write(root.join("debug.log"), b"some log").unwrap();
fs::create_dir(root.join("build")).unwrap();
fs::write(root.join("build/output.rs"), b"generated").unwrap();
let mut tree = SourceHierTree::from(root);
tree.sync();
let events: Vec<ScanEvent> = tree.scan().map(redact_event).collect();
assert!(events
.iter()
.any(|e| matches!(e, ScanEvent::NewFile(p, _) if p == Path::new("main.rs"))));
assert!(!events
.iter()
.any(|e| matches!(e, ScanEvent::NewFile(p, _) if p == Path::new("debug.log"))));
assert!(!events
.iter()
.any(|e| matches!(e, ScanEvent::NewFile(p, _) if p == Path::new("output.rs"))));
let stats = tree.stats();
assert_eq!(stats.files, 1);
}
}