use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::WalkBuilder;
use crate::observer::lang::Language;
#[derive(Debug)]
pub struct ExcludeMatcher {
inner: Option<Gitignore>,
}
impl ExcludeMatcher {
#[must_use]
pub fn empty() -> Self {
Self { inner: None }
}
pub fn compile(root: &Path, patterns: &[String]) -> Result<Self, ignore::Error> {
if patterns.is_empty() {
return Ok(Self::empty());
}
let mut builder = GitignoreBuilder::new(root);
for line in patterns {
builder.add_line(None, line)?;
}
Ok(Self {
inner: Some(builder.build()?),
})
}
#[must_use]
pub fn is_excluded(&self, path: &Path, is_dir: bool) -> bool {
let Some(gi) = self.inner.as_ref() else {
return false;
};
gi.matched_path_or_any_parents(path, is_dir).is_ignore()
}
}
#[must_use]
pub(crate) fn walk_supported_files_under(
root: &Path,
matcher: &ExcludeMatcher,
include_under: Option<&Path>,
) -> Vec<PathBuf> {
let target = resolve_workspace_target(root, include_under, true);
WalkBuilder::new(root)
.require_git(false)
.build()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
.filter_map(|entry| {
let path = entry.into_path();
Language::from_path(&path)?;
if !path_under(&path, target.as_deref()) {
return None;
}
if matcher.is_excluded(&path, false) {
return None;
}
Some(path)
})
.collect()
}
#[must_use]
pub(crate) fn resolve_workspace_target(
root: &Path,
include_under: Option<&Path>,
paths_absolute: bool,
) -> Option<PathBuf> {
let under = include_under?;
if !paths_absolute {
if under.is_absolute() {
return None;
}
return Some(under.to_path_buf());
}
if under.is_absolute() {
Some(under.to_path_buf())
} else {
Some(root.join(under))
}
}
#[must_use]
pub(crate) fn path_under(path: &Path, target: Option<&Path>) -> bool {
target.is_none_or(|t| path.strip_prefix(t).is_ok())
}
#[must_use]
pub(crate) fn since_cutoff(since_days: u32) -> i64 {
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
return i64::MIN;
};
let secs = i64::try_from(now.as_secs()).unwrap_or(i64::MAX);
secs.saturating_sub(i64::from(since_days).saturating_mul(86_400))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn path_under_returns_true_when_target_unset() {
assert!(path_under(Path::new("/proj/anywhere"), None));
}
#[test]
fn path_under_segment_wise_match() {
let target = PathBuf::from("/proj/pkg/web");
assert!(path_under(Path::new("/proj/pkg/web/foo.ts"), Some(&target),));
assert!(!path_under(
Path::new("/proj/pkg/webapp/foo.ts"),
Some(&target),
));
}
#[test]
fn resolve_workspace_target_joins_relative_under_for_absolute_paths() {
let root = PathBuf::from("/proj");
let under = PathBuf::from("pkg/web");
let target =
resolve_workspace_target(&root, Some(&under), true).expect("relative resolves");
assert_eq!(target, PathBuf::from("/proj/pkg/web"));
}
#[test]
fn resolve_workspace_target_keeps_absolute_under_unchanged() {
let root = PathBuf::from("/proj");
let under = PathBuf::from("/other/loc");
let target =
resolve_workspace_target(&root, Some(&under), true).expect("absolute resolves");
assert_eq!(target, PathBuf::from("/other/loc"));
}
#[test]
fn resolve_workspace_target_for_relative_paths_drops_absolute_under() {
let root = PathBuf::from("/proj");
let under = PathBuf::from("/abs/elsewhere");
assert!(resolve_workspace_target(&root, Some(&under), false).is_none());
}
}