codeowners_validation/
parser.rs

1use globset::Glob;
2use std::fs::File;
3use std::io::{self, BufRead};
4
5#[derive(Eq, PartialEq, Clone)]
6pub struct CodeOwnerRule {
7    pub pattern: String,
8    pub owners: Vec<String>,
9    pub original_path: String,
10    pub glob: Glob,
11}
12
13pub struct InvalidLine {
14    pub line_number: usize,
15    pub content: String,
16}
17
18pub fn parse_codeowners_file(
19    file_path: &str,
20) -> io::Result<(Vec<CodeOwnerRule>, Vec<InvalidLine>)> {
21    let file = File::open(file_path)?;
22    let reader = io::BufReader::new(file);
23
24    let mut rules = Vec::new();
25    let mut invalid_lines = Vec::new();
26
27    for (line_number, line_result) in reader.lines().enumerate() {
28        if let Ok(line) = line_result {
29            let trimmed_line = line.trim();
30            if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
31                // Skip empty lines and comments
32                continue;
33            }
34
35            let parts: Vec<&str> = line.split_whitespace().collect();
36            let pattern = parts[0].trim_matches('/').to_string();
37            let owners = parts[1..].iter().map(|s| s.to_string()).collect();
38            let original_path = parts[0].to_string();
39
40            let glob = match Glob::new(&format!("**{}", pattern)) {
41                Ok(glob) => glob,
42                Err(_) => {
43                    let invalid_line = InvalidLine {
44                        line_number: line_number + 1,
45                        content: line,
46                    };
47                    invalid_lines.push(invalid_line);
48                    continue;
49                }
50            };
51
52            let rule = CodeOwnerRule {
53                pattern,
54                owners,
55                original_path,
56                glob,
57            };
58
59            rules.push(rule);
60        }
61    }
62
63    Ok((rules, invalid_lines))
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use std::fs::write;
70    use tempfile::NamedTempFile;
71
72    fn with_temp_codeowners(content: &str) -> NamedTempFile {
73        let file = NamedTempFile::new().unwrap();
74        write(file.path(), content).unwrap();
75        file
76    }
77
78    #[test]
79    fn parses_valid_lines() {
80        let file = with_temp_codeowners("src/lib.rs @alice\n");
81        let (rules, invalids) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
82        assert_eq!(rules.len(), 1);
83        assert_eq!(rules[0].pattern, "src/lib.rs");
84        assert_eq!(rules[0].owners, vec!["@alice"]);
85        assert!(invalids.is_empty());
86    }
87
88    #[test]
89    fn ignores_comments_and_blanks() {
90        let file = with_temp_codeowners("# comment\n\nsrc/main.rs @bob\n");
91        let (rules, _) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
92        assert_eq!(rules.len(), 1);
93        assert_eq!(rules[0].owners, vec!["@bob"]);
94    }
95
96    #[test]
97    fn detects_invalid_glob() {
98        let file = with_temp_codeowners("docs/[ @bad\n");
99        let (_, invalids) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
100        assert_eq!(invalids.len(), 1);
101        assert!(invalids[0].content.contains("docs/["));
102    }
103
104    #[test]
105    fn trims_leading_trailing_slashes() {
106        let file = with_temp_codeowners("/foo/ @team\n");
107        let (rules, _) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
108        assert_eq!(rules[0].pattern, "foo");
109        assert_eq!(rules[0].original_path, "/foo/");
110    }
111
112    #[test]
113    fn parses_multiple_owners() {
114        let file = with_temp_codeowners("src/ @alice @bob\n");
115        let (rules, _) = parse_codeowners_file(file.path().to_str().unwrap()).unwrap();
116        assert_eq!(rules[0].owners, vec!["@alice", "@bob"]);
117    }
118}