1use 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#[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 let content = generate_aggregated_content(self.section_style(), projects, None);
49
50 let output_path = repo_root.join(self.output_path());
52
53 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 let expected = generate_aggregated_content(self.section_style(), projects, None);
72
73 let output_path = repo_root.join(self.output_path());
74
75 let actual = if output_path.exists() {
77 Some(fs::read_to_string(&output_path)?)
78 } else {
79 None
80 };
81
82 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 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 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 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 let result1 = provider.sync(temp.path(), &projects, false).unwrap();
197 assert_eq!(result1.status, SyncStatus::Created);
198
199 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 provider.sync(temp.path(), &projects, false).unwrap();
217
218 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 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 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 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 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 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}