use std::collections::BTreeSet;
use std::path::Path;
use crate::bootstrap::{Bootstrap, Formatter, Limits, Scope};
use crate::git;
use crate::paths::ProjectPath;
use crate::tree::ReadTree;
#[derive(Debug, Clone, Copy)]
pub enum Universe<'a> {
WorkingDirectory {
root: &'a Path,
introduced: &'a [ProjectPath],
},
Frozen,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selected {
pub path: ProjectPath,
pub binary: Option<bool>,
pub formatter: Option<usize>,
}
pub fn select(
tree: &dyn ReadTree,
universe: Universe<'_>,
bootstrap: &Bootstrap,
limits: &Limits,
) -> Result<Vec<Selected>, String> {
let Some(hygiene) = &bootstrap.hygiene else {
return Ok(Vec::new());
};
let mut candidates: BTreeSet<ProjectPath> = BTreeSet::new();
match hygiene.scope {
Scope::Repository => match universe {
Universe::WorkingDirectory { root, introduced } => {
let mut listed = git::working_files(root).map_err(|error| {
format!(
"`hygiene.scope = \"repository\"` needs the project inside a Git repository (declare `scope = \"declared\"` with roots and files otherwise): {error}"
)
})?;
listed.extend(introduced.iter().cloned());
for path in listed {
match tree.symlink_component(&path) {
Ok(Some(_)) => continue,
Ok(None) => {}
Err(error) => {
return Err(format!("cannot inspect `{path}`: {error}"));
}
}
if tree.is_file(&path) {
candidates.insert(path);
}
}
}
Universe::Frozen => {
let walked = tree
.walk(&ProjectPath::root())
.map_err(|error| format!("cannot list the project files: {error}"))?;
candidates.extend(walked);
}
},
Scope::Declared => {
for root in &hygiene.roots {
if !tree.is_dir(root) {
return Err(format!(
"hygiene root `{root}` is not a directory inside the project"
));
}
let walked = tree
.walk(root)
.map_err(|error| format!("cannot walk hygiene root `{root}`: {error}"))?;
candidates.extend(walked);
}
for file in &hygiene.files {
match tree.symlink_component(file) {
Ok(None) => {}
Ok(Some(link)) => {
return Err(format!(
"hygiene file `{file}` is reached through the symbolic link `{link}`; files must not be reached through links"
));
}
Err(error) => {
return Err(format!("cannot inspect hygiene file `{file}`: {error}"));
}
}
if !tree.is_file(file) {
return Err(format!(
"hygiene file `{file}` is not a file inside the project"
));
}
candidates.insert(file.clone());
}
}
}
let within =
|path: &ProjectPath, list: &[ProjectPath]| list.iter().any(|entry| path.is_within(entry));
let mut selected = Vec::new();
for path in candidates {
if within(&path, &hygiene.exclude) {
continue;
}
let binary = if within(&path, &hygiene.binary) {
Some(true)
} else if within(&path, &hygiene.text) {
Some(false)
} else {
None
};
let formatter = assign(&path, &bootstrap.formatters)?;
selected.push(Selected {
path,
binary,
formatter,
});
}
if selected.len() > limits.files {
return Err(format!(
"{} selected files exceed `limits.files` = {}",
selected.len(),
limits.files
));
}
Ok(selected)
}
fn assign(path: &ProjectPath, formatters: &[Formatter]) -> Result<Option<usize>, String> {
let matching: Vec<usize> = formatters
.iter()
.enumerate()
.filter(|(_, formatter)| {
(formatter.paths.is_empty() || formatter.paths.iter().any(|root| path.is_within(root)))
&& (formatter.extensions.is_empty()
|| path.extension().is_some_and(|extension| {
formatter.extensions.iter().any(|e| e == extension)
}))
})
.map(|(index, _)| index)
.collect();
match matching.as_slice() {
[] => Ok(None),
[one] => Ok(Some(*one)),
[first, second, ..] => Err(format!(
"`{path}` is assigned to formatters `{}` and `{}`; a file may have at most one formatter",
formatters[*first].name, formatters[*second].name
)),
}
}