use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct OwnershipConfig {
#[serde(default)]
pub user_owned: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct UserOwnedPaths {
patterns: Vec<glob::Pattern>,
}
impl UserOwnedPaths {
#[must_use]
pub fn none() -> Self {
Self { patterns: Vec::new() }
}
pub fn compile(patterns: &[String]) -> anyhow::Result<Self> {
let mut compiled = Vec::with_capacity(patterns.len());
for pattern in patterns {
let parsed = glob::Pattern::new(pattern).map_err(|error| {
anyhow::anyhow!("[workspace.ownership] user_owned pattern {pattern:?} is not a valid glob: {error}")
})?;
compiled.push(parsed);
}
Ok(Self { patterns: compiled })
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
}
#[must_use]
pub fn matches(&self, base_dir: &Path, full_path: &Path) -> bool {
if self.patterns.is_empty() {
return false;
}
let Ok(relative) = full_path.strip_prefix(base_dir) else {
return false;
};
self.patterns.iter().any(|pattern| pattern.matches_path(relative))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unconfigured_declaration_matches_nothing() {
let declared = UserOwnedPaths::compile(&OwnershipConfig::default().user_owned).expect("compile");
assert!(declared.is_empty());
assert!(!declared.matches(Path::new("/repo"), Path::new("/repo/e2e/node/package.json")));
}
#[test]
fn a_declared_glob_matches_every_path_beneath_it() {
let declared = UserOwnedPaths::compile(&["test_apps/*/Package.swift".to_owned()]).expect("compile");
assert!(declared.matches(Path::new("/repo"), Path::new("/repo/test_apps/swift/Package.swift")));
assert!(!declared.matches(Path::new("/repo"), Path::new("/repo/packages/swift/Package.swift")));
}
#[test]
fn a_path_outside_base_dir_is_never_declared() {
let declared = UserOwnedPaths::compile(&["**".to_owned()]).expect("compile");
assert!(!declared.matches(Path::new("/repo"), Path::new("/elsewhere/e2e/node/package.json")));
}
#[test]
fn a_malformed_pattern_is_a_hard_error_naming_the_pattern() {
let error = UserOwnedPaths::compile(&["e2e/[unclosed".to_owned()]).expect_err("must reject");
let message = format!("{error:#}");
assert!(
message.contains("e2e/[unclosed") && message.contains("user_owned"),
"the error must name the offending pattern and the option it came from: {message}"
);
}
#[test]
fn ownership_config_round_trips_through_toml() {
let parsed: OwnershipConfig =
toml::from_str("user_owned = [\"e2e/*/package.json\"]").expect("parse [workspace.ownership]");
assert_eq!(parsed.user_owned, vec!["e2e/*/package.json".to_owned()]);
}
}