codeowners_validation/
parser.rs1use std::fs::File;
2use std::io::{self, BufRead, BufReader};
3
4#[derive(Debug, Eq, PartialEq, Clone)]
5pub struct CodeOwnerRule {
6 pub pattern: String, pub owners: Vec<String>,
8 pub original_path: String, }
10
11pub struct InvalidLine {
12 pub line_number: usize,
13 pub content: String,
14}
15
16pub fn parse_codeowners_file(
17 file_path: &str,
18) -> io::Result<(Vec<CodeOwnerRule>, Vec<InvalidLine>)> {
19 let file = File::open(file_path)?;
20 let reader = BufReader::with_capacity(64 * 1024, file);
21
22 let mut rules = Vec::with_capacity(1000);
23 let mut invalid_lines = Vec::new();
24
25 for (line_number, line_result) in reader.lines().enumerate() {
26 if let Ok(line) = line_result {
27 let trimmed_line = line.trim();
28 if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
29 continue;
31 }
32
33 let parts: Vec<&str> = line.split_whitespace().collect();
34 if parts.is_empty() {
35 continue;
36 }
37
38 let pattern = parts[0].trim_matches('/').to_string();
39 let owners = parts[1..].iter().map(|s| s.to_string()).collect();
40 let original_path = parts[0].to_string();
41
42 if pattern.is_empty() {
44 let invalid_line = InvalidLine {
45 line_number: line_number + 1,
46 content: line,
47 };
48 invalid_lines.push(invalid_line);
49 continue;
50 }
51
52 if validate_pattern(&pattern, &original_path).is_err() {
54 let invalid_line = InvalidLine {
55 line_number: line_number + 1,
56 content: line,
57 };
58 invalid_lines.push(invalid_line);
59 continue;
60 }
61
62 let rule = CodeOwnerRule {
63 pattern,
64 owners,
65 original_path,
66 };
67
68 rules.push(rule);
69 }
70 }
71
72 rules.shrink_to_fit();
73 invalid_lines.shrink_to_fit();
74
75 Ok((rules, invalid_lines))
76}
77
78fn validate_pattern(pattern: &str, original_path: &str) -> Result<(), &'static str> {
80 use globset::Glob;
81
82 let is_anchored = original_path.starts_with('/');
83 let is_directory = original_path.ends_with('/');
84
85 match (is_anchored, is_directory) {
87 (true, true) => {
88 Glob::new(pattern).map_err(|_| "invalid pattern")?;
90 Glob::new(&format!("{}/**", pattern)).map_err(|_| "invalid pattern")?;
91 }
92 (true, false) => {
93 Glob::new(pattern).map_err(|_| "invalid pattern")?;
95 }
96 (false, true) => {
97 Glob::new(&format!("**/{}", pattern)).map_err(|_| "invalid pattern")?;
99 Glob::new(&format!("**/{}/**", pattern)).map_err(|_| "invalid pattern")?;
100 }
101 (false, false) => {
102 if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
104 Glob::new(pattern).map_err(|_| "invalid pattern")?;
105 } else {
106 Glob::new(&format!("**/{}", pattern)).map_err(|_| "invalid pattern")?;
107 }
108 }
109 }
110
111 Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use std::fs::write;
118 use tempfile::NamedTempFile;
119
120 fn with_temp_codeowners(content: &str) -> NamedTempFile {
121 let file = NamedTempFile::new().unwrap();
122 write(file.path(), content).unwrap();
123 file
124 }
125
126 #[test]
127 fn parses_valid_lines() {
128 let file = with_temp_codeowners("src/lib.rs @alice\n");
129 let (rules, invalids) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
130 assert_eq!(rules.len(), 1);
131 assert_eq!(rules[0].pattern, "src/lib.rs");
132 assert_eq!(rules[0].owners, vec!["@alice"]);
133 assert_eq!(rules[0].original_path, "src/lib.rs");
134 assert!(invalids.is_empty());
135 }
136
137 #[test]
138 fn ignores_comments_and_blanks() {
139 let file = with_temp_codeowners("# comment\n\nsrc/main.rs @bob\n");
140 let (rules, _) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
141 assert_eq!(rules.len(), 1);
142 assert_eq!(rules[0].owners, vec!["@bob"]);
143 }
144
145 #[test]
146 fn detects_invalid_glob() {
147 let file = with_temp_codeowners("docs/[ @bad\n");
148 let (_, invalids) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
149 assert_eq!(invalids.len(), 1);
150 assert!(invalids[0].content.contains("docs/["));
151 }
152
153 #[test]
154 fn trims_leading_trailing_slashes() {
155 let file = with_temp_codeowners("/foo/ @team\n");
156 let (rules, _) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
157 assert_eq!(rules[0].pattern, "foo");
158 assert_eq!(rules[0].original_path, "/foo/");
159 }
160
161 #[test]
162 fn parses_multiple_owners() {
163 let file = with_temp_codeowners("src/ @alice @bob\n");
164 let (rules, _) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
165 assert_eq!(rules[0].owners, vec!["@alice", "@bob"]);
166 }
167
168 #[test]
169 fn handles_wildcard_patterns() {
170 let file = with_temp_codeowners("*.md @docs-team\n**/*.rs @rust-team\n");
171 let (rules, invalids) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
172 assert_eq!(rules.len(), 2);
173 assert!(invalids.is_empty());
174 assert_eq!(rules[0].pattern, "*.md");
175 assert_eq!(rules[1].pattern, "**/*.rs");
176 }
177
178 #[test]
179 fn handles_anchored_patterns() {
180 let file = with_temp_codeowners("/README.md @docs\n/src/ @dev\n");
181 let (rules, _) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
182 assert_eq!(rules.len(), 2);
183 assert_eq!(rules[0].pattern, "README.md");
184 assert_eq!(rules[0].original_path, "/README.md");
185 assert_eq!(rules[1].pattern, "src");
186 assert_eq!(rules[1].original_path, "/src/");
187 }
188
189 #[test]
190 fn rejects_empty_pattern() {
191 let file = with_temp_codeowners("/ @team\n");
192 let (rules, invalids) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
193 assert_eq!(rules.len(), 0);
194 assert_eq!(invalids.len(), 1);
195 }
196}