use std::collections::BTreeSet;
use std::fmt;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use prov_graph::error::Result;
use prov_store::fs::Storage;
use prov_store::index::IndexStore;
use super::Workspace;
type Walked<'a> = Pin<Box<dyn Future<Output = Result<(Vec<Ignore>, bool)>> + 'a>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Reason {
Bookkeeping,
Claimed,
Declared,
Hidden,
Unreached,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ignore {
pub path: String,
pub whole_dir: bool,
pub reason: Reason,
}
impl fmt::Display for Ignore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "/{}", escaped(&self.path))?;
match self.whole_dir {
true => f.write_str("/"),
false => Ok(()),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IgnoreList {
pub rules: Vec<Ignore>,
}
impl IgnoreList {
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn render(&self) -> String {
let mut out = String::new();
for rule in &self.rules {
out.push_str(&rule.to_string());
out.push('\n');
}
out
}
}
impl<FS: Storage, Id, Ix: IndexStore> Workspace<FS, Id, Ix> {
pub async fn ignore_list(&self, root_doc: &Path) -> Result<IgnoreList> {
let scan = Scan {
reachable: slashed(self.reachable_files(root_doc).await?),
bookkeeping: self
.bookkeeping(root_doc)
.await?
.iter()
.filter_map(|path| slash(path))
.collect(),
declared: self
.out_of_scope()
.iter()
.filter_map(|path| slash(path))
.collect(),
};
let (mut rules, _) = walk(self, &scan, String::new()).await?;
rules.sort_by(|a, b| order(a).cmp(&order(b)));
Ok(IgnoreList { rules })
}
async fn bookkeeping(&self, root_doc: &Path) -> Result<Vec<PathBuf>> {
let mut prefixes = Vec::new();
if let Some((index, relation)) = self.deletions_pointer(root_doc).await?
&& Some(relation.as_str()) == self.relations().recycle_relation()
{
prefixes.push(super::store_dir(&index).join("items"));
}
if let Some(about) = self.about_path(root_doc).await? {
prefixes.push(about);
}
Ok(prefixes)
}
}
struct Scan {
reachable: BTreeSet<String>,
bookkeeping: Vec<String>,
declared: Vec<String>,
}
impl Scan {
fn bookkeeping_covers(&self, rel: &str) -> bool {
self.bookkeeping
.iter()
.any(|prefix| rel == prefix || under(rel, prefix))
}
fn declared_covers(&self, rel: &str) -> bool {
self.declared
.iter()
.any(|dir| rel == dir || under(rel, dir))
}
fn reaches_under(&self, rel: &str) -> bool {
self.reachable
.range(format!("{rel}/")..)
.next()
.is_some_and(|path| under(path, rel))
}
}
fn under(path: &str, prefix: &str) -> bool {
path.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
}
fn slash(path: &Path) -> Option<String> {
path.to_str().map(str::to_owned)
}
fn slashed(paths: BTreeSet<PathBuf>) -> BTreeSet<String> {
paths.iter().filter_map(|path| slash(path)).collect()
}
fn order(rule: &Ignore) -> (&str, u8) {
(&rule.path, u8::from(rule.whole_dir))
}
fn escaped(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for character in path.chars() {
if matches!(character, '\\' | '*' | '?' | '[' | ']') {
out.push('\\');
}
out.push(character);
}
if out.ends_with(' ') {
out.insert(out.len() - 1, '\\');
}
out
}
fn walk<'a, FS: Storage, Id, Ix: IndexStore>(
workspace: &'a Workspace<FS, Id, Ix>,
scan: &'a Scan,
rel_dir: String,
) -> Walked<'a> {
Box::pin(async move {
let mut rules = Vec::new();
let mut any_reachable = false;
let Ok(mut entries) = workspace.listing(Path::new(&rel_dir)).await else {
return Ok((rules, any_reachable));
};
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for entry in entries {
let Some(name) = entry
.file_name()
.and_then(|name| name.to_str())
.map(str::to_owned)
else {
continue;
};
let rel = match rel_dir.is_empty() {
true => name.clone(),
false => format!("{rel_dir}/{name}"),
};
if entry.file_type().is_dir() {
if scan.declared_covers(&rel) {
rules.push(dir(rel, Reason::Declared));
continue;
}
if scan.bookkeeping_covers(&rel) {
rules.push(dir(rel, Reason::Bookkeeping));
continue;
}
if workspace
.manifest_node_for(Path::new(&rel))
.await?
.is_some()
{
rules.push(dir(rel, Reason::Claimed));
continue;
}
if name.starts_with('.') && !scan.reaches_under(&rel) {
rules.push(dir(rel, Reason::Hidden));
continue;
}
let (sub, sub_reachable) = walk(workspace, scan, rel.clone()).await?;
any_reachable |= sub_reachable;
let collapses = !sub_reachable
&& !sub.is_empty()
&& sub.iter().all(|rule| rule.reason == Reason::Unreached);
match collapses {
true => rules.push(dir(rel, Reason::Unreached)),
false => rules.extend(sub),
}
} else if entry.file_type().is_file() {
let reason = if scan.declared_covers(&rel) {
Reason::Declared
} else if scan.bookkeeping_covers(&rel) {
Reason::Bookkeeping
} else if scan.reachable.contains(&rel) {
any_reachable = true;
continue;
} else if name.starts_with('.') {
Reason::Hidden
} else {
Reason::Unreached
};
rules.push(Ignore {
path: rel,
whole_dir: false,
reason,
});
}
}
Ok((rules, any_reachable))
})
}
fn dir(path: String, reason: Reason) -> Ignore {
Ignore {
path,
whole_dir: true,
reason,
}
}
#[cfg(all(test, feature = "yaml"))]
mod tests;