codeowners_rs/
ruleset.rs

1use std::path::Path;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6use crate::patternset;
7
8/// `RuleSet` is a collection of CODEOWNERS rules that can be matched together
9/// against a given path. It is constructed by passing a `Vec` of `Rule` structs
10/// to `RuleSet::new`. For convenience, `RuleSet::from_reader` can be used to
11/// parse a CODEOWNERS file and construct a `RuleSet` from it.
12///
13/// # Example
14/// ```
15/// use codeowners_rs::{RuleSet, parse};
16///
17/// let ruleset = parse("*.rs rustacean@example.com").into_ruleset();
18/// assert_eq!(format!("{:?}", ruleset.owners("main.rs")), "Some([Owner { value: \"rustacean@example.com\", kind: Email }])");
19/// ```
20#[derive(Clone)]
21pub struct RuleSet {
22    rules: Vec<Rule>,
23    matcher: patternset::Matcher,
24}
25
26impl RuleSet {
27    /// Construct a `RuleSet` from a `Vec` of `Rule`s.
28    pub fn new(rules: Vec<Rule>) -> Self {
29        let mut builder = patternset::Builder::new();
30        for rule in &rules {
31            builder.add(&rule.pattern);
32        }
33        let matcher = builder.build();
34        Self { rules, matcher }
35    }
36
37    /// Returns the matching rule (if any) for the given path. If multiple rules
38    /// match the path, the last matching rule in the CODEOWNERS file will be
39    /// returned. If no rules match the path, `None` will be returned.
40    pub fn matching_rule(&self, path: impl AsRef<Path>) -> Option<&Rule> {
41        self.matcher
42            .matching_patterns(path)
43            .iter()
44            .max()
45            .map(|&idx| &self.rules[idx])
46    }
47
48    /// Returns the owners for the given path, or `None` if no rules match the
49    /// path or the matching rule has no owners.
50    pub fn owners(&self, path: impl AsRef<Path>) -> Option<&[Owner]> {
51        return self.matching_rule(path).and_then(|rule| {
52            if rule.owners.is_empty() {
53                None
54            } else {
55                Some(rule.owners.as_ref())
56            }
57        });
58    }
59
60    /// Returns the all rules that match the given path along with their indices.
61    /// If multiple rules match the path, the rule with the highest index should
62    /// be considered to be the "winning" rule.
63    pub fn all_matching_rules(&self, path: impl AsRef<Path>) -> Vec<(usize, &Rule)> {
64        self.matcher
65            .matching_patterns(path)
66            .iter()
67            .map(|&idx| (idx, &self.rules[idx]))
68            .collect()
69    }
70}
71
72// `Rule` is an individual CODEOWNERS rule. It contains a pattern and a list of
73// owners.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Rule {
76    pub pattern: String,
77    pub owners: Vec<Owner>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Owner {
82    pub value: String,
83    pub kind: OwnerKind,
84}
85
86impl Owner {
87    pub fn new(value: String, kind: OwnerKind) -> Self {
88        Self { value, kind }
89    }
90}
91
92static EMAIL_REGEX: Lazy<Regex> =
93    Lazy::new(|| Regex::new(r"\A[A-Z0-9a-z\._'%\+\-]+@[A-Za-z0-9\.\-]+\.[A-Za-z]{2,6}\z").unwrap());
94static USERNAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\A@[a-zA-Z0-9\-_]+\z").unwrap());
95static TEAM_REGEX: Lazy<Regex> =
96    Lazy::new(|| Regex::new(r"\A@[a-zA-Z0-9\-]+/[a-zA-Z0-9\-_]+\z").unwrap());
97
98#[derive(Debug, Clone)]
99pub struct InvalidOwnerError {
100    value: String,
101}
102
103impl std::fmt::Display for InvalidOwnerError {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        write!(f, "invalid owner: {}", self.value)
106    }
107}
108
109impl std::error::Error for InvalidOwnerError {}
110
111impl TryFrom<String> for Owner {
112    type Error = InvalidOwnerError;
113
114    fn try_from(value: String) -> Result<Self, Self::Error> {
115        if EMAIL_REGEX.is_match(&value) {
116            Ok(Self::new(value, OwnerKind::Email))
117        } else if USERNAME_REGEX.is_match(&value) {
118            Ok(Self::new(value, OwnerKind::User))
119        } else if TEAM_REGEX.is_match(&value) {
120            Ok(Self::new(value, OwnerKind::Team))
121        } else {
122            Err(InvalidOwnerError { value })
123        }
124    }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum OwnerKind {
129    User,
130    Team,
131    Email,
132}