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