Skip to main content

cuenv_gitlab/
codeowners.rs

1//! GitLab CODEOWNERS provider.
2//!
3//! GitLab supports CODEOWNERS files at:
4//! - `CODEOWNERS` (repository root)
5//! - `docs/CODEOWNERS`
6//! - `.gitlab/CODEOWNERS`
7//!
8//! GitLab uses `[Section]` syntax for sections instead of `# Section`.
9//!
10//! This provider aggregates all project ownership rules into a single file
11//! at the repository root `CODEOWNERS`.
12
13use cuenv_codeowners::SectionStyle;
14use cuenv_codeowners::provider::{
15    CheckResult, CodeOwnersProvider, ProjectOwners, ProviderError, Result, SyncResult,
16    generate_aggregated_content, write_codeowners_file,
17};
18use std::fs;
19use std::path::Path;
20
21/// GitLab CODEOWNERS provider.
22///
23/// Writes a single aggregated CODEOWNERS file to the repository root.
24/// Uses GitLab's `[Section]` syntax for grouping rules.
25#[derive(Debug, Clone, Copy, Default)]
26pub struct GitLabCodeOwnersProvider;
27
28impl CodeOwnersProvider for GitLabCodeOwnersProvider {
29    fn output_path(&self) -> &str {
30        "CODEOWNERS"
31    }
32
33    fn section_style(&self) -> SectionStyle {
34        SectionStyle::Bracket
35    }
36
37    fn sync(
38        &self,
39        repo_root: &Path,
40        projects: &[ProjectOwners],
41        dry_run: bool,
42    ) -> Result<SyncResult> {
43        if projects.is_empty() {
44            return Err(ProviderError::Configuration(
45                "No projects with ownership configuration provided".to_string(),
46            ));
47        }
48
49        // Generate aggregated content with Bracket style (uses [Section] syntax)
50        let content = generate_aggregated_content(self.section_style(), projects, None);
51
52        // Output path is at repo root for GitLab
53        let output_path = repo_root.join(self.output_path());
54
55        // Write the file
56        let status = write_codeowners_file(&output_path, &content, dry_run)?;
57
58        Ok(SyncResult {
59            path: output_path,
60            status,
61            content,
62        })
63    }
64
65    fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult> {
66        if projects.is_empty() {
67            return Err(ProviderError::Configuration(
68                "No projects with ownership configuration provided".to_string(),
69            ));
70        }
71
72        // Generate expected content
73        let expected = generate_aggregated_content(self.section_style(), projects, None);
74
75        let output_path = repo_root.join(self.output_path());
76
77        // Read actual content if file exists
78        let actual = if output_path.exists() {
79            Some(fs::read_to_string(&output_path)?)
80        } else {
81            None
82        };
83
84        // Compare (normalize line endings)
85        let normalize = |s: &str| -> String {
86            s.replace("\r\n", "\n")
87                .lines()
88                .map(str::trim_end)
89                .collect::<Vec<_>>()
90                .join("\n")
91        };
92
93        let in_sync = actual
94            .as_ref()
95            .is_some_and(|a| normalize(a) == normalize(&expected));
96
97        Ok(CheckResult {
98            path: output_path,
99            in_sync,
100            expected,
101            actual,
102        })
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use cuenv_codeowners::Rule;
110    use cuenv_codeowners::provider::SyncStatus;
111    use tempfile::tempdir;
112
113    #[test]
114    fn test_gitlab_provider_output_path() {
115        let provider = GitLabCodeOwnersProvider;
116        assert_eq!(provider.output_path(), "CODEOWNERS");
117    }
118
119    #[test]
120    fn test_gitlab_provider_section_style() {
121        let provider = GitLabCodeOwnersProvider;
122        assert_eq!(provider.section_style(), SectionStyle::Bracket);
123    }
124
125    #[test]
126    fn test_gitlab_sync_creates_file() {
127        let temp = tempdir().unwrap();
128        let provider = GitLabCodeOwnersProvider;
129
130        let projects = vec![ProjectOwners::new(
131            "services/api",
132            "services/api",
133            vec![Rule::new("*.rs", ["@backend-team"])],
134        )];
135
136        let result = provider.sync(temp.path(), &projects, false).unwrap();
137
138        assert_eq!(result.status, SyncStatus::Created);
139        // GitLab uses CODEOWNERS at repo root
140        assert!(result.path.ends_with("CODEOWNERS"));
141        assert!(!result.path.to_string_lossy().contains(".github"));
142        assert!(result.content.contains("/services/api/*.rs @backend-team"));
143
144        // Verify file was written
145        let file_content = fs::read_to_string(&result.path).unwrap();
146        assert_eq!(file_content, result.content);
147    }
148
149    #[test]
150    fn test_gitlab_uses_section_syntax() {
151        let temp = tempdir().unwrap();
152        let provider = GitLabCodeOwnersProvider;
153
154        let projects = vec![
155            ProjectOwners::new(
156                "services/api",
157                "services/api",
158                vec![Rule::new("*.rs", ["@backend-team"])],
159            ),
160            ProjectOwners::new(
161                "services/web",
162                "services/web",
163                vec![Rule::new("*.ts", ["@frontend-team"])],
164            ),
165        ];
166
167        let result = provider.sync(temp.path(), &projects, false).unwrap();
168
169        // GitLab uses [Section] syntax, not # Section
170        assert!(
171            result.content.contains("[services/api]"),
172            "Should use [Section] syntax, got:\n{}",
173            result.content
174        );
175        assert!(
176            result.content.contains("[services/web]"),
177            "Should use [Section] syntax"
178        );
179    }
180
181    #[test]
182    fn test_gitlab_sync_dry_run() {
183        let temp = tempdir().unwrap();
184        let provider = GitLabCodeOwnersProvider;
185
186        let projects = vec![ProjectOwners::new(
187            "services/api",
188            "services/api",
189            vec![Rule::new("*.rs", ["@backend-team"])],
190        )];
191
192        let result = provider.sync(temp.path(), &projects, true).unwrap();
193
194        assert_eq!(result.status, SyncStatus::WouldCreate);
195        assert!(!result.path.exists());
196    }
197
198    #[test]
199    fn test_gitlab_check_in_sync() {
200        let temp = tempdir().unwrap();
201        let provider = GitLabCodeOwnersProvider;
202
203        let projects = vec![ProjectOwners::new(
204            "services/api",
205            "services/api",
206            vec![Rule::new("*.rs", ["@backend-team"])],
207        )];
208
209        // Sync first
210        provider.sync(temp.path(), &projects, false).unwrap();
211
212        // Check should report in sync
213        let result = provider.check(temp.path(), &projects).unwrap();
214        assert!(result.in_sync);
215    }
216
217    #[test]
218    fn test_gitlab_check_out_of_sync() {
219        let temp = tempdir().unwrap();
220        let provider = GitLabCodeOwnersProvider;
221
222        // Create file with different content
223        fs::write(temp.path().join("CODEOWNERS"), "# Different content\n").unwrap();
224
225        let projects = vec![ProjectOwners::new(
226            "services/api",
227            "services/api",
228            vec![Rule::new("*.rs", ["@backend-team"])],
229        )];
230
231        let result = provider.check(temp.path(), &projects).unwrap();
232        assert!(!result.in_sync);
233    }
234
235    #[test]
236    fn test_gitlab_empty_projects_error() {
237        let temp = tempdir().unwrap();
238        let provider = GitLabCodeOwnersProvider;
239
240        let result = provider.sync(temp.path(), &[], false);
241        assert!(result.is_err());
242    }
243}