use std::borrow::Cow;
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crate::config::validate_group_name;
use crate::hook::Hook;
use crate::warn_user;
use anyhow::anyhow;
use itertools::Itertools;
use owo_colors::OwoColorize;
use prek_consts::env_vars::{EnvVars, EnvVarsRead};
use rustc_hash::FxHashSet;
use tracing::trace;
use crate::fs::PathClean;
#[derive(Debug, Clone, Copy)]
pub(crate) struct ConfiguredHook<'a> {
project_relative_path: &'a Path,
id: &'a str,
alias: Option<&'a str>,
groups: Option<&'a [String]>,
}
impl<'a> ConfiguredHook<'a> {
pub(crate) fn new(
project_relative_path: &'a Path,
id: &'a str,
alias: Option<&'a str>,
groups: Option<&'a [String]>,
) -> Self {
Self {
project_relative_path,
id,
alias,
groups,
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error("Invalid selector: `{selector}`")]
InvalidSelector {
selector: String,
#[source]
source: anyhow::Error,
},
#[error("Invalid project path: `{path}`")]
InvalidPath {
path: String,
#[source]
source: anyhow::Error,
},
#[error("Invalid group selector: `{selector}`")]
GroupSelector {
selector: String,
#[source]
source: anyhow::Error,
},
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum SelectorSource {
CliArg,
CliFlag(&'static str),
EnvVar(&'static str),
}
#[derive(Debug, Clone)]
pub(crate) enum SelectorExpr {
HookId(String),
ProjectPrefix(PathBuf),
ProjectHook {
project_path: PathBuf,
hook_id: String,
},
}
#[derive(Debug, Clone)]
pub(crate) struct Selector {
source: SelectorSource,
original: String,
expr: SelectorExpr,
}
impl Display for Selector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.expr {
SelectorExpr::HookId(hook_id) => write!(f, "{hook_id}"),
SelectorExpr::ProjectPrefix(project_path) => {
if project_path.as_os_str().is_empty() {
write!(f, "./")
} else {
write!(f, "{}/", project_path.display())
}
}
SelectorExpr::ProjectHook {
project_path,
hook_id,
} => {
if project_path.as_os_str().is_empty() {
write!(f, ".:{hook_id}")
} else {
write!(f, "{}:{hook_id}", project_path.display())
}
}
}
}
}
impl Selector {
pub(crate) fn as_flag(&self) -> Cow<'_, str> {
match &self.source {
SelectorSource::CliArg => Cow::Borrowed(&self.original),
SelectorSource::CliFlag(flag) => Cow::Owned(format!("{}={}", flag, self.original)),
SelectorSource::EnvVar(var) => Cow::Owned(format!("{}={}", var, self.original)),
}
}
pub(crate) fn as_normalized_flag(&self) -> String {
match &self.source {
SelectorSource::CliArg => self.to_string(),
SelectorSource::CliFlag(flag) => format!("{flag}={self}"),
SelectorSource::EnvVar(var) => format!("{var}={self}"),
}
}
pub(crate) fn source(&self) -> &SelectorSource {
&self.source
}
pub(crate) fn kind_str(&self) -> &'static str {
match &self.expr {
SelectorExpr::HookId(_) | SelectorExpr::ProjectHook { .. } => "hooks",
SelectorExpr::ProjectPrefix(_) => "projects",
}
}
}
impl Selector {
pub(crate) fn matches_hook(&self, hook: &Hook) -> bool {
match &self.expr {
SelectorExpr::HookId(hook_id) => {
&hook.id == hook_id || &hook.alias == hook_id
}
SelectorExpr::ProjectPrefix(project_path) => {
hook.project().relative_path().starts_with(project_path)
}
SelectorExpr::ProjectHook {
project_path,
hook_id,
} => {
(&hook.id == hook_id || &hook.alias == hook_id)
&& project_path == hook.project().relative_path()
}
}
}
fn matches_configured_hook(&self, hook: &ConfiguredHook<'_>) -> bool {
let matches_hook_id = |selector: &str| {
hook.id == selector || hook.alias.is_some_and(|alias| alias == selector)
};
match &self.expr {
SelectorExpr::HookId(selector) => matches_hook_id(selector),
SelectorExpr::ProjectPrefix(project_path) => {
hook.project_relative_path.starts_with(project_path)
}
SelectorExpr::ProjectHook {
project_path,
hook_id: selector_hook_id,
} => {
project_path.as_path() == hook.project_relative_path
&& matches_hook_id(selector_hook_id)
}
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Selectors {
includes: Vec<Selector>,
skips: Vec<Selector>,
usage: Arc<Mutex<FilterUsage>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum HookSelection {
Selected,
Skipped,
NotSelected,
}
impl Selectors {
pub(crate) fn from_include(include: &str, workspace_root: &Path) -> Result<Self, Error> {
let include = parse_single_selector(
include,
workspace_root,
SelectorSource::CliArg,
RealFileSystem,
)?;
trace!("Include selector: `{include}`");
Ok(Self {
includes: vec![include],
skips: Vec::new(),
usage: Arc::default(),
})
}
pub(crate) fn load(
includes: &[String],
skips: &[String],
workspace_root: &Path,
) -> Result<Selectors, Error> {
let includes = includes
.iter()
.unique()
.map(|selector| {
parse_single_selector(
selector,
workspace_root,
SelectorSource::CliArg,
RealFileSystem,
)
})
.collect::<Result<Vec<_>, _>>()?;
trace!(
"Include selectors: `{}`",
includes
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
let skips = load_skips(skips, workspace_root, RealFileSystem)?;
trace!(
"Skip selectors: `{}`",
skips
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
);
Ok(Self {
includes,
skips,
usage: Arc::default(),
})
}
pub(crate) fn includes(&self) -> &[Selector] {
&self.includes
}
pub(crate) fn skips(&self) -> &[Selector] {
&self.skips
}
pub(crate) fn has_project_selectors(&self) -> bool {
self.includes.iter().any(|include| {
matches!(
include.expr,
SelectorExpr::ProjectPrefix(_) | SelectorExpr::ProjectHook { .. }
)
})
}
pub(crate) fn includes_only_hook_targets(&self) -> bool {
!self.includes.is_empty()
&& self.includes.iter().all(|s| {
matches!(
s.expr,
SelectorExpr::HookId(_) | SelectorExpr::ProjectHook { .. }
)
})
}
pub(super) fn select_hook(&self, hook: &Hook) -> HookSelection {
let mut usage = self.usage.lock().unwrap();
let mut skipped = false;
for (idx, skip) in self.skips.iter().enumerate() {
if skip.matches_hook(hook) {
usage.use_exclude(idx);
skipped = true;
}
}
let included = if self.includes.is_empty() {
true
} else {
let mut included = false;
for (idx, include) in self.includes.iter().enumerate() {
if include.matches_hook(hook) {
usage.use_include(idx);
included = true;
}
}
included
};
if !included {
HookSelection::NotSelected
} else if skipped {
HookSelection::Skipped
} else {
HookSelection::Selected
}
}
pub(crate) fn matches_hook(&self, hook: &Hook) -> bool {
self.select_hook(hook) == HookSelection::Selected
}
pub(crate) fn excludes_configured_hook(&self, hook: &ConfiguredHook<'_>) -> bool {
let mut usage = self.usage.lock().unwrap();
let mut skipped = false;
for (idx, skip) in self.skips.iter().enumerate() {
if skip.matches_configured_hook(hook) {
usage.use_exclude(idx);
skipped = true;
}
}
skipped
}
pub(crate) fn matches_hook_id(&self, hook_id: &str) -> bool {
let mut usage = self.usage.lock().unwrap();
let mut skipped = false;
for (idx, skip) in self.skips.iter().enumerate() {
if let SelectorExpr::HookId(id) = &skip.expr {
if id == hook_id {
usage.use_exclude(idx);
skipped = true;
}
}
}
if skipped {
return false;
}
if self.includes.is_empty() {
return true; }
let mut included = false;
for (idx, include) in self.includes.iter().enumerate() {
if let SelectorExpr::HookId(id) = &include.expr {
if id == hook_id {
usage.use_include(idx);
included = true;
}
}
}
included
}
pub(crate) fn matches_path(&self, path: &Path) -> bool {
let mut usage = self.usage.lock().unwrap();
let mut skipped = false;
for (idx, skip) in self.skips.iter().enumerate() {
if let SelectorExpr::ProjectPrefix(project_path) = &skip.expr {
if path.starts_with(project_path) {
usage.use_exclude(idx);
skipped = true;
}
}
}
if skipped {
return false;
}
if self.includes.is_empty() {
return true;
}
let mut included = false;
for (idx, include) in self.includes.iter().enumerate() {
match &include.expr {
SelectorExpr::HookId(_) => {
included = true;
}
SelectorExpr::ProjectPrefix(project_path) => {
if path.starts_with(project_path) {
usage.use_include(idx);
included = true;
}
}
SelectorExpr::ProjectHook { project_path, .. } => {
if path == project_path {
included = true;
}
}
}
}
included
}
pub(crate) fn report_unused(&self) {
let usage = self.usage.lock().unwrap();
usage.report_unused(self);
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct GroupFilters {
include_any: Vec<GroupSelector>,
require_all: Vec<GroupSelector>,
exclude_any: Vec<GroupSelector>,
usage: Arc<Mutex<FilterUsage>>,
}
#[derive(Debug, Clone)]
enum GroupSelector {
Named(String),
Ungrouped,
}
const UNGROUPED_GROUP: &str = "@ungrouped";
impl GroupSelector {
fn parse(group: &str) -> Result<Self, &'static str> {
if group == UNGROUPED_GROUP {
Ok(Self::Ungrouped)
} else {
validate_group_name(group)?;
Ok(Self::Named(group.to_owned()))
}
}
}
impl Display for GroupSelector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Named(group) => f.write_str(group),
Self::Ungrouped => f.write_str(UNGROUPED_GROUP),
}
}
}
impl GroupFilters {
pub(crate) fn parse(
include_any: &[String],
require_all: &[String],
exclude_any: &[String],
) -> Result<Self, Error> {
let parse_groups = |flag: &'static str, groups: &[String]| {
let mut seen = FxHashSet::default();
let mut selectors = Vec::new();
for group in groups {
let selector =
GroupSelector::parse(group).map_err(|reason| Error::GroupSelector {
selector: format!("{flag}={group}"),
source: anyhow!("group name {reason}"),
})?;
if seen.insert(group.as_str()) {
selectors.push(selector);
}
}
Ok(selectors)
};
Ok(Self {
include_any: parse_groups("--group", include_any)?,
require_all: parse_groups("--require-group", require_all)?,
exclude_any: parse_groups("--no-group", exclude_any)?,
usage: Arc::default(),
})
}
pub(crate) fn has_filters(&self) -> bool {
!self.include_any.is_empty() || !self.require_all.is_empty() || !self.exclude_any.is_empty()
}
fn matches_groups(&self, contains_group: impl Fn(&GroupSelector) -> bool) -> bool {
let mut usage = self.usage.lock().unwrap();
let mut matches_any_excluded = false;
for (idx, exclude) in self.exclude_any.iter().enumerate() {
if contains_group(exclude) {
usage.use_exclude(idx);
matches_any_excluded = true;
}
}
let mut matches_any_included = self.include_any.is_empty();
for (idx, include) in self.include_any.iter().enumerate() {
if contains_group(include) {
usage.use_include(idx);
matches_any_included = true;
}
}
let mut matches_all_required = true;
for (idx, requirement) in self.require_all.iter().enumerate() {
if contains_group(requirement) {
usage.use_requirement(idx);
} else {
matches_all_required = false;
}
}
matches_any_included && matches_all_required && !matches_any_excluded
}
pub(crate) fn matches_hook(&self, hook: &Hook) -> bool {
self.matches_groups(|group| match group {
GroupSelector::Named(group) => hook.groups.contains(group),
GroupSelector::Ungrouped => hook.groups.is_empty(),
})
}
pub(crate) fn matches_configured_hook(&self, hook: &ConfiguredHook<'_>) -> bool {
self.matches_groups(|group| match group {
GroupSelector::Named(group) => hook
.groups
.is_some_and(|groups| groups.iter().any(|hook_group| hook_group == group)),
GroupSelector::Ungrouped => hook.groups.is_none_or(<[String]>::is_empty),
})
}
pub(crate) fn report_unused(&self) {
let usage = self.usage.lock().unwrap();
let unused = usage
.unused_includes(&self.include_any)
.map(|(_, group)| format!("--group={group}"))
.chain(
usage
.unused_requirements(&self.require_all)
.map(|(_, group)| format!("--require-group={group}")),
)
.chain(
usage
.unused_excludes(&self.exclude_any)
.map(|(_, group)| format!("--no-group={group}")),
)
.collect::<Vec<_>>();
match unused.as_slice() {
[] => {}
[group] => {
warn_user!("group selector `{group}` did not match any hooks");
}
_ => {
let warning = unused
.iter()
.map(|group| format!(" - `{group}`"))
.collect::<Vec<_>>()
.join("\n");
warn_user!("the following group selectors did not match any hooks:");
anstream::eprintln!("{warning}");
}
}
}
}
#[derive(Default, Debug)]
struct FilterUsage {
includes: FxHashSet<usize>,
requirements: FxHashSet<usize>,
excludes: FxHashSet<usize>,
}
impl FilterUsage {
fn use_include(&mut self, idx: usize) {
self.includes.insert(idx);
}
fn use_requirement(&mut self, idx: usize) {
self.requirements.insert(idx);
}
fn use_exclude(&mut self, idx: usize) {
self.excludes.insert(idx);
}
fn unused_includes<'a, T>(
&'a self,
values: &'a [T],
) -> impl Iterator<Item = (usize, &'a T)> + 'a {
values
.iter()
.enumerate()
.filter(|(idx, _)| !self.includes.contains(idx))
}
fn unused_excludes<'a, T>(
&'a self,
values: &'a [T],
) -> impl Iterator<Item = (usize, &'a T)> + 'a {
values
.iter()
.enumerate()
.filter(|(idx, _)| !self.excludes.contains(idx))
}
fn unused_requirements<'a, T>(
&'a self,
values: &'a [T],
) -> impl Iterator<Item = (usize, &'a T)> + 'a {
values
.iter()
.enumerate()
.filter(|(idx, _)| !self.requirements.contains(idx))
}
fn report_unused(&self, selectors: &Selectors) {
let unused = self
.unused_includes(&selectors.includes)
.chain(self.unused_excludes(&selectors.skips))
.collect::<Vec<_>>();
match unused.as_slice() {
[] => {}
[(_, selector)] => {
let flag = selector.as_flag();
let normalized = selector.as_normalized_flag();
if flag == normalized {
warn_user!(
"selector `{flag}` did not match any {}",
selector.kind_str()
);
} else {
warn_user!(
"selector `{flag}` ({}) did not match any {}",
format!("normalized to `{normalized}`").dimmed(),
selector.kind_str()
);
}
}
_ => {
let warning = unused
.iter()
.map(|(_, sel)| {
let flag = sel.as_flag();
let normalized = sel.as_normalized_flag();
if flag == normalized {
format!(" - `{flag}`")
} else {
format!(
" - `{flag}` ({})",
format!("normalized to `{normalized}`").dimmed()
)
}
})
.collect::<Vec<_>>()
.join("\n");
warn_user!("the following selectors did not match any hooks or projects:");
anstream::eprintln!("{warning}");
}
}
}
}
fn parse_single_selector<FS: FileSystem>(
input: &str,
workspace_root: &Path,
source: SelectorSource,
fs: FS,
) -> Result<Selector, Error> {
if let Some((project_path, hook_id)) = input.split_once(':') {
if hook_id.is_empty() {
return Err(Error::InvalidSelector {
selector: input.to_string(),
source: anyhow!("hook ID part is empty"),
});
}
if project_path.is_empty() {
return Ok(Selector {
source,
original: input.to_string(),
expr: SelectorExpr::HookId(hook_id.to_string()),
});
}
let project_path = normalize_path(project_path, workspace_root, fs).map_err(|e| {
Error::InvalidSelector {
selector: input.to_string(),
source: anyhow!(e),
}
})?;
return Ok(Selector {
source,
original: input.to_string(),
expr: SelectorExpr::ProjectHook {
project_path,
hook_id: hook_id.to_string(),
},
});
}
if input == "." || input.contains('/') {
let project_path =
normalize_path(input, workspace_root, fs).map_err(|e| Error::InvalidSelector {
selector: input.to_string(),
source: anyhow!(e),
})?;
return Ok(Selector {
source,
original: input.to_string(),
expr: SelectorExpr::ProjectPrefix(project_path),
});
}
if input.is_empty() {
return Err(Error::InvalidSelector {
selector: input.to_string(),
source: anyhow!("cannot be empty"),
});
}
Ok(Selector {
source,
original: input.to_string(),
expr: SelectorExpr::HookId(input.to_string()),
})
}
pub trait FileSystem: Copy {
fn absolute<P: AsRef<Path>>(&self, path: P) -> std::io::Result<PathBuf>;
}
#[derive(Copy, Clone)]
pub struct RealFileSystem;
impl FileSystem for RealFileSystem {
fn absolute<P: AsRef<Path>>(&self, path: P) -> std::io::Result<PathBuf> {
Ok(std::path::absolute(path)?.clean())
}
}
fn normalize_path<FS: FileSystem>(
path: &str,
workspace_root: &Path,
fs: FS,
) -> Result<PathBuf, Error> {
let absolute_path = fs.absolute(path).map_err(|e| Error::InvalidPath {
path: path.to_string(),
source: anyhow!(e),
})?;
let absolute_path = absolute_path.clean();
let rel_path = absolute_path
.strip_prefix(workspace_root)
.map_err(|_| Error::InvalidPath {
path: path.to_string(),
source: anyhow!("path is outside the workspace root"),
})?;
Ok(rel_path.to_path_buf())
}
pub(crate) fn load_skips<FS: FileSystem>(
cli_skips: &[String],
workspace_root: &Path,
fs: FS,
) -> Result<Vec<Selector>, Error> {
let prek_skip = EnvVars.var(EnvVars::PREK_SKIP);
let skip = EnvVars.var(EnvVars::SKIP);
let (skips, source) = if !cli_skips.is_empty() {
(
cli_skips.iter().map(String::as_str).collect::<Vec<_>>(),
SelectorSource::CliFlag("--skip"),
)
} else if let Ok(s) = &prek_skip {
(
parse_comma_separated(s).collect(),
SelectorSource::EnvVar(EnvVars::PREK_SKIP),
)
} else if let Ok(s) = &skip {
(
parse_comma_separated(s).collect(),
SelectorSource::EnvVar(EnvVars::SKIP),
)
} else {
return Ok(vec![]);
};
skips
.into_iter()
.unique()
.map(|skip| parse_single_selector(skip, workspace_root, source, fs))
.collect()
}
fn parse_comma_separated(input: &str) -> impl Iterator<Item = &str> {
input.split(',').map(str::trim).filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::*;
use tempfile::TempDir;
struct MockFileSystem {
current_dir: TempDir,
}
impl FileSystem for &MockFileSystem {
fn absolute<P: AsRef<Path>>(&self, path: P) -> std::io::Result<PathBuf> {
let p = path.as_ref();
if p.is_absolute() {
Ok(p.to_path_buf())
} else {
Ok(self.current_dir.path().join(p))
}
}
}
impl MockFileSystem {
fn root(&self) -> &Path {
self.current_dir.path()
}
}
fn create_test_workspace() -> anyhow::Result<MockFileSystem> {
let temp_dir = TempDir::new()?;
fs_err::create_dir_all(temp_dir.path().join("src"))?;
fs_err::create_dir_all(temp_dir.path().join("src/backend"))?;
Ok(MockFileSystem {
current_dir: temp_dir,
})
}
#[test]
fn test_parse_single_selector_hook_id() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
let selector = parse_single_selector(":black", fs.root(), SelectorSource::CliArg, &fs)?;
assert_matches!(selector.expr, SelectorExpr::HookId(ref id) if id == "black");
let selector = parse_single_selector(":lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?;
assert_matches!(selector.expr, SelectorExpr::HookId(ref id) if id == "lint:ruff");
let selector = parse_single_selector("black", fs.root(), SelectorSource::CliArg, &fs)?;
assert_matches!(selector.expr, SelectorExpr::HookId(ref id) if id == "black");
Ok(())
}
#[test]
fn test_parse_single_selector_project_prefix() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
let selector = parse_single_selector("src/", fs.root(), SelectorSource::CliArg, &fs)?;
assert_matches!(
selector.expr,
SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from("src")
);
let selector = parse_single_selector(".", fs.root(), SelectorSource::CliArg, &fs)?;
assert_matches!(
selector.expr,
SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from("")
);
let selector = parse_single_selector("./", fs.root(), SelectorSource::CliArg, &fs)?;
assert_matches!(
selector.expr,
SelectorExpr::ProjectPrefix(ref path) if path == &PathBuf::from("")
);
Ok(())
}
#[test]
fn test_parse_single_selector_project_hook() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
let selector = parse_single_selector("src:black", fs.root(), SelectorSource::CliArg, &fs)?;
match selector.expr {
SelectorExpr::ProjectHook {
project_path,
hook_id,
} => {
assert_eq!(project_path, PathBuf::from("src"));
assert_eq!(hook_id, "black");
}
_ => panic!("Expected ProjectHook"),
}
let selector =
parse_single_selector("src:lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?;
match selector.expr {
SelectorExpr::ProjectHook {
project_path,
hook_id,
} => {
assert_eq!(project_path, PathBuf::from("src"));
assert_eq!(hook_id, "lint:ruff");
}
_ => panic!("Expected ProjectHook"),
}
Ok(())
}
#[test]
fn test_parse_single_selector_invalid() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
let result = parse_single_selector(":", fs.root(), SelectorSource::CliArg, &fs);
assert!(result.is_err());
let result = parse_single_selector("src:", fs.root(), SelectorSource::CliArg, &fs);
assert!(result.is_err());
let result = parse_single_selector("", fs.root(), SelectorSource::CliArg, &fs);
assert!(result.is_err());
Ok(())
}
#[test]
fn test_normalize_path() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
let result = normalize_path("src", fs.root(), &fs)?;
assert_eq!(result, PathBuf::from("src"));
let result = normalize_path("src/backend", fs.root(), &fs)?;
assert_eq!(result, PathBuf::from("src/backend"));
let result = normalize_path(".", fs.root(), &fs)?;
assert_eq!(result, PathBuf::from(""));
let outside_dir = TempDir::new()?;
let outside_path = outside_dir.path().to_string_lossy();
let result = normalize_path(&outside_path, fs.root(), &fs);
assert!(result.is_err());
Ok(())
}
#[test]
fn test_selector_display() -> anyhow::Result<()> {
let fs = create_test_workspace()?;
let selector = parse_single_selector("black", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "black");
let selector = parse_single_selector(":black", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "black");
let selector = parse_single_selector(":lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "lint:ruff");
let selector = parse_single_selector("src/", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "src/");
let selector = parse_single_selector("./src/", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "src/");
let selector = parse_single_selector("src/", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "src/");
let selector = parse_single_selector(".", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "./");
let selector = parse_single_selector("./", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "./");
let selector = parse_single_selector("src:black", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "src:black");
let selector =
parse_single_selector("./src:black", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "src:black");
let selector =
parse_single_selector("./src/:black", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "src:black");
let selector =
parse_single_selector("src:lint:ruff", fs.root(), SelectorSource::CliArg, &fs)?;
assert_eq!(selector.to_string(), "src:lint:ruff");
Ok(())
}
fn test_selector(expr: SelectorExpr) -> Selector {
Selector {
source: SelectorSource::CliArg,
original: "selector".to_string(),
expr,
}
}
fn configured_hook(
project_relative_path: &'static str,
id: &'static str,
alias: Option<&'static str>,
) -> ConfiguredHook<'static> {
ConfiguredHook::new(Path::new(project_relative_path), id, alias, None)
}
#[test]
fn test_selector_matches_configured_hook() {
let cases = [
(
"hook id matches id",
test_selector(SelectorExpr::HookId("black".to_string())),
configured_hook("src", "black", None),
true,
),
(
"hook id matches config alias",
test_selector(SelectorExpr::HookId("black".to_string())),
configured_hook("src", "ruff", Some("black")),
true,
),
(
"project prefix matches nested project",
test_selector(SelectorExpr::ProjectPrefix(PathBuf::from("src"))),
configured_hook("src/backend", "black", None),
true,
),
(
"project prefix rejects other project",
test_selector(SelectorExpr::ProjectPrefix(PathBuf::from("src"))),
configured_hook("tests", "black", None),
false,
),
(
"project hook matches exact project and id",
test_selector(SelectorExpr::ProjectHook {
project_path: PathBuf::from("src"),
hook_id: "black".to_string(),
}),
configured_hook("src", "black", None),
true,
),
(
"project hook matches exact project and config alias",
test_selector(SelectorExpr::ProjectHook {
project_path: PathBuf::from("src"),
hook_id: "black".to_string(),
}),
configured_hook("src", "ruff", Some("black")),
true,
),
(
"project hook rejects nested project prefix",
test_selector(SelectorExpr::ProjectHook {
project_path: PathBuf::from("src"),
hook_id: "black".to_string(),
}),
configured_hook("src/backend", "black", None),
false,
),
(
"project hook rejects other hook",
test_selector(SelectorExpr::ProjectHook {
project_path: PathBuf::from("src"),
hook_id: "black".to_string(),
}),
configured_hook("src", "ruff", Some("format")),
false,
),
];
for (name, selector, hook, expected) in cases {
assert_eq!(selector.matches_configured_hook(&hook), expected, "{name}");
}
}
#[test]
fn test_selector_as_flag() {
let selector = Selector {
source: SelectorSource::CliArg,
original: "black".to_string(),
expr: SelectorExpr::HookId("black".to_string()),
};
assert_eq!(selector.as_flag(), "black");
let selector = Selector {
source: SelectorSource::CliFlag("--skip"),
original: "black".to_string(),
expr: SelectorExpr::HookId("black".to_string()),
};
assert_eq!(selector.as_flag(), "--skip=black");
let selector = Selector {
source: SelectorSource::EnvVar("SKIP"),
original: "black".to_string(),
expr: SelectorExpr::HookId("black".to_string()),
};
assert_eq!(selector.as_flag(), "SKIP=black");
}
}