codeowners_parser/
validation.rs1use glob::Pattern;
9use regex::Regex;
10use std::sync::LazyLock;
11
12static TEAM_RE: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r"^@[a-zA-Z0-9-]+/[a-zA-Z0-9-]+$").unwrap());
14static USER_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^@[a-zA-Z0-9-]+$").unwrap());
15static EMAIL_RE: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap());
17
18pub fn validate_owner(owner: &str) -> Option<String> {
20 if TEAM_RE.is_match(owner) || USER_RE.is_match(owner) || EMAIL_RE.is_match(owner) {
21 None
22 } else {
23 Some(format!(
24 "Invalid owner format '{}'. Expected @user, @org/team, or email@domain.com",
25 owner
26 ))
27 }
28}
29
30pub fn validate_pattern(pattern: &str) -> Option<String> {
32 let pattern_for_glob = pattern.trim_start_matches('/');
34
35 if pattern_for_glob.is_empty() {
37 return Some("Empty pattern".to_string());
38 }
39
40 if pattern.starts_with('!') {
44 return Some(
45 "CODEOWNERS does not support '!' negation; the rule will never match".to_string(),
46 );
47 }
48 if pattern.contains('[') {
49 return Some(
50 "CODEOWNERS does not support '[...]' character ranges; the rule will never match"
51 .to_string(),
52 );
53 }
54
55 if let Err(e) = Pattern::new(pattern_for_glob) {
57 return Some(format!("Invalid glob pattern: {}", e));
58 }
59
60 None
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
69 fn test_valid_user() {
70 assert!(validate_owner("@username").is_none());
71 assert!(validate_owner("@user-name").is_none());
72 assert!(validate_owner("@user123").is_none());
73 }
74
75 #[test]
76 fn test_valid_team() {
77 assert!(validate_owner("@org/team").is_none());
78 assert!(validate_owner("@my-org/my-team").is_none());
79 }
80
81 #[test]
82 fn test_invalid_owner_with_underscore() {
83 assert!(validate_owner("@user_name").is_some());
85 assert!(validate_owner("@org_name/team_name").is_some());
86 }
87
88 #[test]
89 fn test_valid_email() {
90 assert!(validate_owner("user@example.com").is_none());
91 assert!(validate_owner("user.name@domain.co.uk").is_none());
92 assert!(validate_owner("user+tag@example.org").is_none());
93 }
94
95 #[test]
96 fn test_invalid_owner_with_period() {
97 assert!(validate_owner("@user.name").is_some());
99 assert!(validate_owner("@org.name/team").is_some());
100 }
101
102 #[test]
103 fn test_invalid_owner_no_at() {
104 assert!(validate_owner("username").is_some());
105 }
106
107 #[test]
108 fn test_invalid_owner_empty() {
109 assert!(validate_owner("").is_some());
110 assert!(validate_owner("@").is_some());
111 }
112
113 #[test]
114 fn test_invalid_owner_spaces() {
115 assert!(validate_owner("@user name").is_some());
116 assert!(validate_owner("user @name").is_some());
117 }
118
119 #[test]
120 fn test_invalid_team_format() {
121 assert!(validate_owner("@org/").is_some());
122 assert!(validate_owner("@/team").is_some());
123 assert!(validate_owner("@org//team").is_some());
124 }
125
126 #[test]
128 fn test_valid_patterns() {
129 assert!(validate_pattern("*").is_none());
130 assert!(validate_pattern("**").is_none());
131 assert!(validate_pattern("*.rs").is_none());
132 assert!(validate_pattern("/src/").is_none());
133 assert!(validate_pattern("/src/**").is_none());
134 assert!(validate_pattern("docs/*.md").is_none());
135 }
136
137 #[test]
138 fn test_invalid_empty_pattern() {
139 assert!(validate_pattern("").is_some());
140 assert!(validate_pattern("/").is_some());
141 }
142
143 #[test]
144 fn test_invalid_glob_syntax() {
145 assert!(validate_pattern("[invalid").is_some());
147 }
148
149 #[test]
150 fn test_rejects_syntax_codeowners_does_not_support() {
151 assert!(validate_pattern("[abc].txt").is_some());
154 assert!(validate_pattern("src/[a-z]*.rs").is_some());
155 assert!(validate_pattern("!docs/").is_some());
156 assert!(validate_pattern("!/src/main.rs").is_some());
157
158 assert!(validate_pattern("docs/hello!.md").is_none());
160 }
161}