1use cuenv_codeowners::{Codeowners, CodeownersBuilder};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fmt;
13use std::path::Path;
14
15pub use cuenv_codeowners::Platform as LibPlatform;
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
23#[serde(rename_all = "lowercase")]
24pub enum Platform {
25 #[default]
27 Github,
28 Gitlab,
30 Bitbucket,
32}
33
34impl Platform {
35 #[must_use]
37 pub fn default_path(&self) -> &'static str {
38 self.to_lib().default_path()
39 }
40
41 #[must_use]
43 pub fn to_lib(self) -> LibPlatform {
44 match self {
45 Self::Github => LibPlatform::Github,
46 Self::Gitlab => LibPlatform::Gitlab,
47 Self::Bitbucket => LibPlatform::Bitbucket,
48 }
49 }
50
51 #[must_use]
53 pub fn from_lib(platform: LibPlatform) -> Self {
54 match platform {
55 LibPlatform::Github => Self::Github,
56 LibPlatform::Gitlab => Self::Gitlab,
57 LibPlatform::Bitbucket => Self::Bitbucket,
58 }
59 }
60}
61
62impl fmt::Display for Platform {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 write!(f, "{}", self.to_lib())
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
70pub struct OwnersOutput {
71 pub platform: Option<Platform>,
73
74 pub path: Option<String>,
76
77 pub header: Option<String>,
79}
80
81impl OwnersOutput {
82 #[must_use]
84 pub fn output_path(&self) -> &str {
85 if let Some(ref path) = self.path {
86 path
87 } else {
88 self.platform.unwrap_or_default().default_path()
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct OwnerRule {
96 pub pattern: String,
98
99 pub owners: Vec<String>,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub description: Option<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub section: Option<String>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub order: Option<i32>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
121#[serde(rename_all = "camelCase")]
122pub struct Owners {
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub output: Option<OwnersOutput>,
126
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub default_owners: Option<Vec<String>>,
130
131 #[serde(default)]
134 pub rules: HashMap<String, OwnerRule>,
135}
136
137impl Owners {
138 #[must_use]
143 pub fn to_codeowners(&self) -> Codeowners {
144 let mut builder = CodeownersBuilder::default();
145
146 if let Some(ref output) = self.output {
148 if let Some(platform) = output.platform {
149 builder = builder.platform(platform.to_lib());
150 }
151 if let Some(ref path) = output.path {
152 builder = builder.path(path.clone());
153 }
154 if let Some(ref header) = output.header {
155 builder = builder.header(header.clone());
156 } else {
157 builder = builder.header(
159 "CODEOWNERS file - Generated by cuenv\n\
160 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
161 );
162 }
163 } else {
164 builder = builder.header(
166 "CODEOWNERS file - Generated by cuenv\n\
167 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
168 );
169 }
170
171 if let Some(ref default_owners) = self.default_owners {
173 builder = builder.default_owners(default_owners.clone());
174 }
175
176 let mut rule_entries: Vec<_> = self.rules.iter().collect();
178 rule_entries.sort_by(|a, b| {
179 let order_a = a.1.order.unwrap_or(i32::MAX);
180 let order_b = b.1.order.unwrap_or(i32::MAX);
181 order_a.cmp(&order_b).then_with(|| a.0.cmp(b.0))
182 });
183
184 for (_key, rule) in rule_entries {
185 let mut lib_rule = cuenv_codeowners::Rule::new(&rule.pattern, rule.owners.clone());
186 if let Some(ref description) = rule.description {
187 lib_rule = lib_rule.description(description.clone());
188 }
189 if let Some(ref section) = rule.section {
190 lib_rule = lib_rule.section(section.clone());
191 }
192 builder = builder.rule(lib_rule);
193 }
194
195 builder.build()
196 }
197
198 #[must_use]
203 pub fn generate(&self) -> String {
204 self.to_codeowners().generate()
205 }
206
207 #[must_use]
209 pub fn output_path(&self) -> &str {
210 self.output
211 .as_ref()
212 .map(|o| o.output_path())
213 .unwrap_or_else(|| Platform::default().default_path())
214 }
215
216 #[must_use]
220 pub fn detect_platform(repo_root: &Path) -> Platform {
221 Platform::from_lib(Codeowners::detect_platform(repo_root))
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_platform_default_paths() {
231 assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
232 assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
233 assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
234 }
235
236 #[test]
237 fn test_owners_output_path() {
238 let owners = Owners::default();
240 assert_eq!(owners.output_path(), ".github/CODEOWNERS");
241
242 let owners = Owners {
244 output: Some(OwnersOutput {
245 platform: Some(Platform::Gitlab),
246 path: None,
247 header: None,
248 }),
249 ..Default::default()
250 };
251 assert_eq!(owners.output_path(), "CODEOWNERS");
252
253 let owners = Owners {
255 output: Some(OwnersOutput {
256 platform: Some(Platform::Github),
257 path: Some("docs/CODEOWNERS".to_string()),
258 header: None,
259 }),
260 ..Default::default()
261 };
262 assert_eq!(owners.output_path(), "docs/CODEOWNERS");
263 }
264
265 #[test]
266 fn test_generate_simple() {
267 let mut rules = HashMap::new();
268 rules.insert(
269 "rust-files".to_string(),
270 OwnerRule {
271 pattern: "*.rs".to_string(),
272 owners: vec!["@rust-team".to_string()],
273 description: None,
274 section: None,
275 order: Some(1),
276 },
277 );
278 rules.insert(
279 "docs".to_string(),
280 OwnerRule {
281 pattern: "/docs/**".to_string(),
282 owners: vec!["@docs-team".to_string(), "@tech-writers".to_string()],
283 description: None,
284 section: None,
285 order: Some(2),
286 },
287 );
288
289 let owners = Owners {
290 rules,
291 ..Default::default()
292 };
293
294 let content = owners.generate();
295 assert!(content.contains("*.rs @rust-team"));
296 assert!(content.contains("/docs/** @docs-team @tech-writers"));
297 }
298
299 #[test]
300 fn test_generate_with_sections() {
301 let mut rules = HashMap::new();
302 rules.insert(
303 "rust-files".to_string(),
304 OwnerRule {
305 pattern: "*.rs".to_string(),
306 owners: vec!["@backend".to_string()],
307 description: Some("Rust source files".to_string()),
308 section: Some("Backend".to_string()),
309 order: Some(1),
310 },
311 );
312 rules.insert(
313 "typescript-files".to_string(),
314 OwnerRule {
315 pattern: "*.ts".to_string(),
316 owners: vec!["@frontend".to_string()],
317 description: None,
318 section: Some("Frontend".to_string()),
319 order: Some(2),
320 },
321 );
322
323 let owners = Owners {
324 rules,
325 ..Default::default()
326 };
327
328 let content = owners.generate();
329 assert!(content.contains("# Backend"));
330 assert!(content.contains("# Rust source files"));
331 assert!(content.contains("# Frontend"));
332 }
333
334 #[test]
335 fn test_generate_with_default_owners() {
336 let mut rules = HashMap::new();
337 rules.insert(
338 "security".to_string(),
339 OwnerRule {
340 pattern: "/security/**".to_string(),
341 owners: vec!["@security-team".to_string()],
342 description: None,
343 section: None,
344 order: None,
345 },
346 );
347
348 let owners = Owners {
349 default_owners: Some(vec!["@core-team".to_string()]),
350 rules,
351 ..Default::default()
352 };
353
354 let content = owners.generate();
355 assert!(content.contains("* @core-team"));
356 assert!(content.contains("/security/** @security-team"));
357 }
358
359 #[test]
360 fn test_generate_with_custom_header() {
361 let owners = Owners {
362 output: Some(OwnersOutput {
363 platform: None,
364 path: None,
365 header: Some("Custom Header\nLine 2".to_string()),
366 }),
367 rules: HashMap::new(),
368 ..Default::default()
369 };
370
371 let content = owners.generate();
372 assert!(content.contains("# Custom Header"));
373 assert!(content.contains("# Line 2"));
374 }
375
376 #[test]
377 fn test_generate_gitlab_sections() {
378 let mut rules = HashMap::new();
379 rules.insert(
380 "rust-files".to_string(),
381 OwnerRule {
382 pattern: "*.rs".to_string(),
383 owners: vec!["@backend".to_string()],
384 section: Some("Backend".to_string()),
385 description: None,
386 order: Some(1),
387 },
388 );
389 rules.insert(
390 "typescript-files".to_string(),
391 OwnerRule {
392 pattern: "*.ts".to_string(),
393 owners: vec!["@frontend".to_string()],
394 section: Some("Frontend".to_string()),
395 description: None,
396 order: Some(2),
397 },
398 );
399
400 let owners = Owners {
401 output: Some(OwnersOutput {
402 platform: Some(Platform::Gitlab),
403 path: None,
404 header: None,
405 }),
406 rules,
407 ..Default::default()
408 };
409
410 let content = owners.generate();
411 assert!(
413 content.contains("[Backend]"),
414 "GitLab should use [Section] syntax, got: {content}"
415 );
416 assert!(
417 content.contains("[Frontend]"),
418 "GitLab should use [Section] syntax, got: {content}"
419 );
420 assert!(
422 !content.contains("# Backend"),
423 "GitLab should NOT use # Section"
424 );
425 assert!(
426 !content.contains("# Frontend"),
427 "GitLab should NOT use # Section"
428 );
429 }
430
431 #[test]
432 fn test_generate_groups_rules_by_section() {
433 let mut rules = HashMap::new();
436 rules.insert(
437 "rust-files".to_string(),
438 OwnerRule {
439 pattern: "*.rs".to_string(),
440 owners: vec!["@backend".to_string()],
441 section: Some("Backend".to_string()),
442 description: None,
443 order: Some(1),
444 },
445 );
446 rules.insert(
447 "typescript-files".to_string(),
448 OwnerRule {
449 pattern: "*.ts".to_string(),
450 owners: vec!["@frontend".to_string()],
451 section: Some("Frontend".to_string()),
452 description: None,
453 order: Some(2),
454 },
455 );
456 rules.insert(
457 "go-files".to_string(),
458 OwnerRule {
459 pattern: "*.go".to_string(),
460 owners: vec!["@backend".to_string()],
461 section: Some("Backend".to_string()),
462 description: None,
463 order: Some(3),
464 },
465 );
466
467 let owners = Owners {
468 rules,
469 ..Default::default()
470 };
471
472 let content = owners.generate();
473 let backend_count = content.matches("# Backend").count();
475 assert_eq!(
476 backend_count, 1,
477 "Backend section should appear exactly once, found {backend_count} times"
478 );
479 let backend_idx = content.find("# Backend").unwrap();
481 let rs_idx = content.find("*.rs").unwrap();
482 let go_idx = content.find("*.go").unwrap();
483 let frontend_idx = content.find("# Frontend").unwrap();
484 assert!(
486 rs_idx > backend_idx && rs_idx < frontend_idx,
487 "*.rs should be in Backend section"
488 );
489 assert!(
490 go_idx > backend_idx && go_idx < frontend_idx,
491 "*.go should be in Backend section"
492 );
493 }
494
495 #[test]
496 fn test_order_sorting() {
497 let mut rules = HashMap::new();
499 rules.insert(
500 "z-last".to_string(),
501 OwnerRule {
502 pattern: "*.last".to_string(),
503 owners: vec!["@team".to_string()],
504 description: None,
505 section: None,
506 order: Some(3),
507 },
508 );
509 rules.insert(
510 "a-first".to_string(),
511 OwnerRule {
512 pattern: "*.first".to_string(),
513 owners: vec!["@team".to_string()],
514 description: None,
515 section: None,
516 order: Some(1),
517 },
518 );
519 rules.insert(
520 "m-middle".to_string(),
521 OwnerRule {
522 pattern: "*.middle".to_string(),
523 owners: vec!["@team".to_string()],
524 description: None,
525 section: None,
526 order: Some(2),
527 },
528 );
529
530 let owners = Owners {
531 rules,
532 ..Default::default()
533 };
534
535 let content = owners.generate();
536 let first_idx = content.find("*.first").unwrap();
537 let middle_idx = content.find("*.middle").unwrap();
538 let last_idx = content.find("*.last").unwrap();
539
540 assert!(
541 first_idx < middle_idx && middle_idx < last_idx,
542 "Rules should be sorted by order: first={first_idx}, middle={middle_idx}, last={last_idx}"
543 );
544 }
545}