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(default)]
130 pub rules: HashMap<String, OwnerRule>,
131}
132
133impl Owners {
134 #[must_use]
139 pub fn to_codeowners(&self) -> Codeowners {
140 let mut builder = CodeownersBuilder::default();
141
142 if let Some(ref output) = self.output {
144 if let Some(platform) = output.platform {
145 builder = builder.platform(platform.to_lib());
146 }
147 if let Some(ref path) = output.path {
148 builder = builder.path(path.clone());
149 }
150 if let Some(ref header) = output.header {
151 builder = builder.header(header.clone());
152 } else {
153 builder = builder.header(
155 "CODEOWNERS file - Generated by cuenv\n\
156 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
157 );
158 }
159 } else {
160 builder = builder.header(
162 "CODEOWNERS file - Generated by cuenv\n\
163 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
164 );
165 }
166
167 let mut rule_entries: Vec<_> = self.rules.iter().collect();
169 rule_entries.sort_by(|a, b| {
170 let order_a = a.1.order.unwrap_or(i32::MAX);
171 let order_b = b.1.order.unwrap_or(i32::MAX);
172 order_a.cmp(&order_b).then_with(|| a.0.cmp(b.0))
173 });
174
175 for (_key, rule) in rule_entries {
176 let mut lib_rule = cuenv_codeowners::Rule::new(&rule.pattern, rule.owners.clone());
177 if let Some(ref description) = rule.description {
178 lib_rule = lib_rule.description(description.clone());
179 }
180 if let Some(ref section) = rule.section {
181 lib_rule = lib_rule.section(section.clone());
182 }
183 builder = builder.rule(lib_rule);
184 }
185
186 builder.build()
187 }
188
189 #[must_use]
194 pub fn generate(&self) -> String {
195 self.to_codeowners().generate()
196 }
197
198 #[must_use]
200 pub fn output_path(&self) -> &str {
201 self.output
202 .as_ref()
203 .map(|o| o.output_path())
204 .unwrap_or_else(|| Platform::default().default_path())
205 }
206
207 #[must_use]
211 pub fn detect_platform(repo_root: &Path) -> Platform {
212 Platform::from_lib(Codeowners::detect_platform(repo_root))
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_platform_default_paths() {
222 assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
223 assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
224 assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
225 }
226
227 #[test]
228 fn test_owners_output_path() {
229 let owners = Owners::default();
231 assert_eq!(owners.output_path(), ".github/CODEOWNERS");
232
233 let owners = Owners {
235 output: Some(OwnersOutput {
236 platform: Some(Platform::Gitlab),
237 path: None,
238 header: None,
239 }),
240 ..Default::default()
241 };
242 assert_eq!(owners.output_path(), "CODEOWNERS");
243
244 let owners = Owners {
246 output: Some(OwnersOutput {
247 platform: Some(Platform::Github),
248 path: Some("docs/CODEOWNERS".to_string()),
249 header: None,
250 }),
251 ..Default::default()
252 };
253 assert_eq!(owners.output_path(), "docs/CODEOWNERS");
254 }
255
256 #[test]
257 fn test_generate_simple() {
258 let mut rules = HashMap::new();
259 rules.insert(
260 "rust-files".to_string(),
261 OwnerRule {
262 pattern: "*.rs".to_string(),
263 owners: vec!["@rust-team".to_string()],
264 description: None,
265 section: None,
266 order: Some(1),
267 },
268 );
269 rules.insert(
270 "docs".to_string(),
271 OwnerRule {
272 pattern: "/docs/**".to_string(),
273 owners: vec!["@docs-team".to_string(), "@tech-writers".to_string()],
274 description: None,
275 section: None,
276 order: Some(2),
277 },
278 );
279
280 let owners = Owners {
281 rules,
282 ..Default::default()
283 };
284
285 let content = owners.generate();
286 assert!(content.contains("*.rs @rust-team"));
287 assert!(content.contains("/docs/** @docs-team @tech-writers"));
288 }
289
290 #[test]
291 fn test_generate_with_sections() {
292 let mut rules = HashMap::new();
293 rules.insert(
294 "rust-files".to_string(),
295 OwnerRule {
296 pattern: "*.rs".to_string(),
297 owners: vec!["@backend".to_string()],
298 description: Some("Rust source files".to_string()),
299 section: Some("Backend".to_string()),
300 order: Some(1),
301 },
302 );
303 rules.insert(
304 "typescript-files".to_string(),
305 OwnerRule {
306 pattern: "*.ts".to_string(),
307 owners: vec!["@frontend".to_string()],
308 description: None,
309 section: Some("Frontend".to_string()),
310 order: Some(2),
311 },
312 );
313
314 let owners = Owners {
315 rules,
316 ..Default::default()
317 };
318
319 let content = owners.generate();
320 assert!(content.contains("# Backend"));
321 assert!(content.contains("# Rust source files"));
322 assert!(content.contains("# Frontend"));
323 }
324
325 #[test]
326 fn test_generate_with_custom_header() {
327 let owners = Owners {
328 output: Some(OwnersOutput {
329 platform: None,
330 path: None,
331 header: Some("Custom Header\nLine 2".to_string()),
332 }),
333 rules: HashMap::new(),
334 };
335
336 let content = owners.generate();
337 assert!(content.contains("# Custom Header"));
338 assert!(content.contains("# Line 2"));
339 }
340
341 #[test]
342 fn test_generate_gitlab_sections() {
343 let mut rules = HashMap::new();
344 rules.insert(
345 "rust-files".to_string(),
346 OwnerRule {
347 pattern: "*.rs".to_string(),
348 owners: vec!["@backend".to_string()],
349 section: Some("Backend".to_string()),
350 description: None,
351 order: Some(1),
352 },
353 );
354 rules.insert(
355 "typescript-files".to_string(),
356 OwnerRule {
357 pattern: "*.ts".to_string(),
358 owners: vec!["@frontend".to_string()],
359 section: Some("Frontend".to_string()),
360 description: None,
361 order: Some(2),
362 },
363 );
364
365 let owners = Owners {
366 output: Some(OwnersOutput {
367 platform: Some(Platform::Gitlab),
368 path: None,
369 header: None,
370 }),
371 rules,
372 };
373
374 let content = owners.generate();
375 assert!(
377 content.contains("[Backend]"),
378 "GitLab should use [Section] syntax, got: {content}"
379 );
380 assert!(
381 content.contains("[Frontend]"),
382 "GitLab should use [Section] syntax, got: {content}"
383 );
384 assert!(
386 !content.contains("# Backend"),
387 "GitLab should NOT use # Section"
388 );
389 assert!(
390 !content.contains("# Frontend"),
391 "GitLab should NOT use # Section"
392 );
393 }
394
395 #[test]
396 fn test_generate_groups_rules_by_section() {
397 let mut rules = HashMap::new();
400 rules.insert(
401 "rust-files".to_string(),
402 OwnerRule {
403 pattern: "*.rs".to_string(),
404 owners: vec!["@backend".to_string()],
405 section: Some("Backend".to_string()),
406 description: None,
407 order: Some(1),
408 },
409 );
410 rules.insert(
411 "typescript-files".to_string(),
412 OwnerRule {
413 pattern: "*.ts".to_string(),
414 owners: vec!["@frontend".to_string()],
415 section: Some("Frontend".to_string()),
416 description: None,
417 order: Some(2),
418 },
419 );
420 rules.insert(
421 "go-files".to_string(),
422 OwnerRule {
423 pattern: "*.go".to_string(),
424 owners: vec!["@backend".to_string()],
425 section: Some("Backend".to_string()),
426 description: None,
427 order: Some(3),
428 },
429 );
430
431 let owners = Owners {
432 rules,
433 ..Default::default()
434 };
435
436 let content = owners.generate();
437 let backend_count = content.matches("# Backend").count();
439 assert_eq!(
440 backend_count, 1,
441 "Backend section should appear exactly once, found {backend_count} times"
442 );
443 let backend_idx = content.find("# Backend").unwrap();
445 let rs_idx = content.find("*.rs").unwrap();
446 let go_idx = content.find("*.go").unwrap();
447 let frontend_idx = content.find("# Frontend").unwrap();
448 assert!(
450 rs_idx > backend_idx && rs_idx < frontend_idx,
451 "*.rs should be in Backend section"
452 );
453 assert!(
454 go_idx > backend_idx && go_idx < frontend_idx,
455 "*.go should be in Backend section"
456 );
457 }
458
459 #[test]
460 fn test_order_sorting() {
461 let mut rules = HashMap::new();
463 rules.insert(
464 "z-last".to_string(),
465 OwnerRule {
466 pattern: "*.last".to_string(),
467 owners: vec!["@team".to_string()],
468 description: None,
469 section: None,
470 order: Some(3),
471 },
472 );
473 rules.insert(
474 "a-first".to_string(),
475 OwnerRule {
476 pattern: "*.first".to_string(),
477 owners: vec!["@team".to_string()],
478 description: None,
479 section: None,
480 order: Some(1),
481 },
482 );
483 rules.insert(
484 "m-middle".to_string(),
485 OwnerRule {
486 pattern: "*.middle".to_string(),
487 owners: vec!["@team".to_string()],
488 description: None,
489 section: None,
490 order: Some(2),
491 },
492 );
493
494 let owners = Owners {
495 rules,
496 ..Default::default()
497 };
498
499 let content = owners.generate();
500 let first_idx = content.find("*.first").unwrap();
501 let middle_idx = content.find("*.middle").unwrap();
502 let last_idx = content.find("*.last").unwrap();
503
504 assert!(
505 first_idx < middle_idx && middle_idx < last_idx,
506 "Rules should be sorted by order: first={first_idx}, middle={middle_idx}, last={last_idx}"
507 );
508 }
509}