codeowners_validation/
parser.rs

1use std::fs::File;
2use std::io::{self, BufRead, BufReader};
3
4#[derive(Debug, Eq, PartialEq, Clone)]
5pub struct CodeOwnerRule {
6    pub pattern: String, // Normalized pattern (no leading/trailing /)
7    pub owners: Vec<String>,
8    pub original_path: String, // Original path from file (with / if present)
9}
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                // Skip empty lines and comments
30                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            // Basic validation - ensure pattern is not empty after trimming
43            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            // Check for invalid glob patterns
53            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
78// Validate that the pattern can be turned into valid globs
79fn 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    // Test that we can create the necessary globs
86    match (is_anchored, is_directory) {
87        (true, true) => {
88            // /docs/ → need to create "docs" and "docs/**"
89            Glob::new(pattern).map_err(|_| "invalid pattern")?;
90            Glob::new(&format!("{}/**", pattern)).map_err(|_| "invalid pattern")?;
91        }
92        (true, false) => {
93            // /src/file.rs → need to create "src/file.rs"
94            Glob::new(pattern).map_err(|_| "invalid pattern")?;
95        }
96        (false, true) => {
97            // lib/ → need to create "**/lib" and "**/lib/**"
98            Glob::new(&format!("**/{}", pattern)).map_err(|_| "invalid pattern")?;
99            Glob::new(&format!("**/{}/**", pattern)).map_err(|_| "invalid pattern")?;
100        }
101        (false, false) => {
102            // *.rs or file.txt → need to create pattern or "**/pattern"
103            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}