Skip to main content

cuenv_github/
codeowners.rs

1//! GitHub CODEOWNERS provider.
2//!
3//! GitHub requires a single CODEOWNERS file at one of these locations:
4//! - `.github/CODEOWNERS` (most common)
5//! - `docs/CODEOWNERS`
6//! - `CODEOWNERS` (repository root)
7//!
8//! This provider aggregates all project ownership rules into a single file
9//! at `.github/CODEOWNERS`, with patterns prefixed by project paths.
10
11use cuenv_codeowners::SectionStyle;
12use cuenv_codeowners::provider::{
13    CheckResult, CodeOwnersProvider, ProjectOwners, ProviderError, Result, SyncResult,
14    generate_aggregated_content, write_codeowners_file,
15};
16use std::fs;
17use std::path::Path;
18
19/// GitHub CODEOWNERS provider.
20///
21/// Writes a single aggregated CODEOWNERS file to `.github/CODEOWNERS`.
22/// Uses comment-style sections (`# Section Name`).
23#[derive(Debug, Clone, Copy, Default)]
24pub struct GitHubCodeOwnersProvider;
25
26impl CodeOwnersProvider for GitHubCodeOwnersProvider {
27    fn output_path(&self) -> &str {
28        ".github/CODEOWNERS"
29    }
30
31    fn section_style(&self) -> SectionStyle {
32        SectionStyle::Comment
33    }
34
35    fn sync(
36        &self,
37        repo_root: &Path,
38        projects: &[ProjectOwners],
39        dry_run: bool,
40    ) -> Result<SyncResult> {
41        if projects.is_empty() {
42            return Err(ProviderError::Configuration(
43                "No projects with ownership configuration provided".to_string(),
44            ));
45        }
46
47        // Generate aggregated content
48        let content = generate_aggregated_content(self.section_style(), projects, None);
49
50        // Output path is always at repo root
51        let output_path = repo_root.join(self.output_path());
52
53        // Write the file
54        let status = write_codeowners_file(&output_path, &content, dry_run)?;
55
56        Ok(SyncResult {
57            path: output_path,
58            status,
59            content,
60        })
61    }
62
63    fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult> {
64        if projects.is_empty() {
65            return Err(ProviderError::Configuration(
66                "No projects with ownership configuration provided".to_string(),
67            ));
68        }
69
70        // Generate expected content
71        let expected = generate_aggregated_content(self.section_style(), projects, None);
72
73        let output_path = repo_root.join(self.output_path());
74
75        // Read actual content if file exists
76        let actual = if output_path.exists() {
77            Some(fs::read_to_string(&output_path)?)
78        } else {
79            None
80        };
81
82        // Compare (normalize line endings)
83        let normalize = |s: &str| -> String {
84            s.replace("\r\n", "\n")
85                .lines()
86                .map(str::trim_end)
87                .collect::<Vec<_>>()
88                .join("\n")
89        };
90
91        let in_sync = actual
92            .as_ref()
93            .is_some_and(|a| normalize(a) == normalize(&expected));
94
95        Ok(CheckResult {
96            path: output_path,
97            in_sync,
98            expected,
99            actual,
100        })
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use cuenv_codeowners::Rule;
108    use cuenv_codeowners::provider::SyncStatus;
109    use tempfile::tempdir;
110
111    #[test]
112    fn test_github_provider_output_path() {
113        let provider = GitHubCodeOwnersProvider;
114        assert_eq!(provider.output_path(), ".github/CODEOWNERS");
115    }
116
117    #[test]
118    fn test_github_provider_section_style() {
119        let provider = GitHubCodeOwnersProvider;
120        assert_eq!(provider.section_style(), SectionStyle::Comment);
121    }
122
123    #[test]
124    fn test_github_sync_creates_file() {
125        let temp = tempdir().unwrap();
126        let provider = GitHubCodeOwnersProvider;
127
128        let projects = vec![ProjectOwners::new(
129            "services/api",
130            "services/api",
131            vec![Rule::new("*.rs", ["@backend-team"])],
132        )];
133
134        let result = provider.sync(temp.path(), &projects, false).unwrap();
135
136        assert_eq!(result.status, SyncStatus::Created);
137        assert!(result.path.ends_with(".github/CODEOWNERS"));
138        assert!(result.content.contains("/services/api/*.rs @backend-team"));
139
140        // Verify file was written
141        let file_content = fs::read_to_string(&result.path).unwrap();
142        assert_eq!(file_content, result.content);
143    }
144
145    #[test]
146    fn test_github_sync_dry_run() {
147        let temp = tempdir().unwrap();
148        let provider = GitHubCodeOwnersProvider;
149
150        let projects = vec![ProjectOwners::new(
151            "services/api",
152            "services/api",
153            vec![Rule::new("*.rs", ["@backend-team"])],
154        )];
155
156        let result = provider.sync(temp.path(), &projects, true).unwrap();
157
158        assert_eq!(result.status, SyncStatus::WouldCreate);
159        // File should NOT exist in dry-run mode
160        assert!(!result.path.exists());
161    }
162
163    #[test]
164    fn test_github_sync_updates_file() {
165        let temp = tempdir().unwrap();
166        let provider = GitHubCodeOwnersProvider;
167
168        // Create initial file
169        let github_dir = temp.path().join(".github");
170        fs::create_dir_all(&github_dir).unwrap();
171        fs::write(github_dir.join("CODEOWNERS"), "# Old content\n").unwrap();
172
173        let projects = vec![ProjectOwners::new(
174            "services/api",
175            "services/api",
176            vec![Rule::new("*.rs", ["@backend-team"])],
177        )];
178
179        let result = provider.sync(temp.path(), &projects, false).unwrap();
180
181        assert_eq!(result.status, SyncStatus::Updated);
182    }
183
184    #[test]
185    fn test_github_sync_unchanged() {
186        let temp = tempdir().unwrap();
187        let provider = GitHubCodeOwnersProvider;
188
189        let projects = vec![ProjectOwners::new(
190            "services/api",
191            "services/api",
192            vec![Rule::new("*.rs", ["@backend-team"])],
193        )];
194
195        // First sync creates the file
196        let result1 = provider.sync(temp.path(), &projects, false).unwrap();
197        assert_eq!(result1.status, SyncStatus::Created);
198
199        // Second sync should be unchanged
200        let result2 = provider.sync(temp.path(), &projects, false).unwrap();
201        assert_eq!(result2.status, SyncStatus::Unchanged);
202    }
203
204    #[test]
205    fn test_github_check_in_sync() {
206        let temp = tempdir().unwrap();
207        let provider = GitHubCodeOwnersProvider;
208
209        let projects = vec![ProjectOwners::new(
210            "services/api",
211            "services/api",
212            vec![Rule::new("*.rs", ["@backend-team"])],
213        )];
214
215        // Sync first
216        provider.sync(temp.path(), &projects, false).unwrap();
217
218        // Check should report in sync
219        let result = provider.check(temp.path(), &projects).unwrap();
220        assert!(result.in_sync);
221    }
222
223    #[test]
224    fn test_github_check_out_of_sync() {
225        let temp = tempdir().unwrap();
226        let provider = GitHubCodeOwnersProvider;
227
228        // Create file with different content
229        let github_dir = temp.path().join(".github");
230        fs::create_dir_all(&github_dir).unwrap();
231        fs::write(github_dir.join("CODEOWNERS"), "# Different content\n").unwrap();
232
233        let projects = vec![ProjectOwners::new(
234            "services/api",
235            "services/api",
236            vec![Rule::new("*.rs", ["@backend-team"])],
237        )];
238
239        let result = provider.check(temp.path(), &projects).unwrap();
240        assert!(!result.in_sync);
241        assert!(result.actual.is_some());
242    }
243
244    #[test]
245    fn test_github_check_missing_file() {
246        let temp = tempdir().unwrap();
247        let provider = GitHubCodeOwnersProvider;
248
249        let projects = vec![ProjectOwners::new(
250            "services/api",
251            "services/api",
252            vec![Rule::new("*.rs", ["@backend-team"])],
253        )];
254
255        let result = provider.check(temp.path(), &projects).unwrap();
256        assert!(!result.in_sync);
257        assert!(result.actual.is_none());
258    }
259
260    #[test]
261    fn test_github_aggregates_multiple_projects() {
262        let temp = tempdir().unwrap();
263        let provider = GitHubCodeOwnersProvider;
264
265        let projects = vec![
266            ProjectOwners::new(
267                "services/api",
268                "services/api",
269                vec![Rule::new("*.rs", ["@backend-team"])],
270            ),
271            ProjectOwners::new(
272                "services/web",
273                "services/web",
274                vec![Rule::new("*.ts", ["@frontend-team"])],
275            ),
276            ProjectOwners::new(
277                "libs/common",
278                "libs/common",
279                vec![Rule::new("*.rs", ["@platform-team"])],
280            ),
281        ];
282
283        let result = provider.sync(temp.path(), &projects, false).unwrap();
284
285        // All projects should be in the single file
286        assert!(result.content.contains("/services/api/*.rs @backend-team"));
287        assert!(result.content.contains("/services/web/*.ts @frontend-team"));
288        assert!(result.content.contains("/libs/common/*.rs @platform-team"));
289
290        // Sections should be present
291        assert!(result.content.contains("# services/api"));
292        assert!(result.content.contains("# services/web"));
293        assert!(result.content.contains("# libs/common"));
294    }
295
296    #[test]
297    fn test_github_root_project() {
298        let temp = tempdir().unwrap();
299        let provider = GitHubCodeOwnersProvider;
300
301        // Project at repo root
302        let projects = vec![ProjectOwners::new(
303            "",
304            "root",
305            vec![
306                Rule::new("*.rs", ["@core-team"]),
307                Rule::new("/docs/**", ["@docs-team"]),
308            ],
309        )];
310
311        let result = provider.sync(temp.path(), &projects, false).unwrap();
312
313        // Root patterns should be normalized
314        assert!(result.content.contains("/*.rs @core-team"));
315        assert!(result.content.contains("/docs/** @docs-team"));
316    }
317
318    #[test]
319    fn test_github_empty_projects_error() {
320        let temp = tempdir().unwrap();
321        let provider = GitHubCodeOwnersProvider;
322
323        let result = provider.sync(temp.path(), &[], false);
324        assert!(result.is_err());
325    }
326}