1#![warn(missing_docs)]
34
35use std::collections::BTreeMap;
36use std::fmt;
37use std::path::Path;
38
39#[cfg(feature = "schemars")]
40use schemars::JsonSchema;
41#[cfg(feature = "serde")]
42use serde::{Deserialize, Serialize};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
53#[cfg_attr(feature = "schemars", derive(JsonSchema))]
54pub enum Platform {
55 #[default]
57 Github,
58 Gitlab,
60 Bitbucket,
62}
63
64impl Platform {
65 #[must_use]
76 pub fn default_path(&self) -> &'static str {
77 match self {
78 Platform::Github => ".github/CODEOWNERS",
79 Platform::Gitlab | Platform::Bitbucket => "CODEOWNERS",
80 }
81 }
82}
83
84impl fmt::Display for Platform {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 Platform::Github => write!(f, "github"),
88 Platform::Gitlab => write!(f, "gitlab"),
89 Platform::Bitbucket => write!(f, "bitbucket"),
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
109#[cfg_attr(feature = "schemars", derive(JsonSchema))]
110pub struct Rule {
111 pub pattern: String,
113 pub owners: Vec<String>,
115 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
117 pub description: Option<String>,
118 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
120 pub section: Option<String>,
121}
122
123impl Rule {
124 pub fn new(
135 pattern: impl Into<String>,
136 owners: impl IntoIterator<Item = impl Into<String>>,
137 ) -> Self {
138 Self {
139 pattern: pattern.into(),
140 owners: owners.into_iter().map(Into::into).collect(),
141 description: None,
142 section: None,
143 }
144 }
145
146 #[must_use]
150 pub fn description(mut self, description: impl Into<String>) -> Self {
151 self.description = Some(description.into());
152 self
153 }
154
155 #[must_use]
159 pub fn section(mut self, section: impl Into<String>) -> Self {
160 self.section = Some(section.into());
161 self
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Default)]
184#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
185#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
186#[cfg_attr(feature = "schemars", derive(JsonSchema))]
187pub struct Codeowners {
188 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
190 pub platform: Option<Platform>,
191 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
193 pub path: Option<String>,
194 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
196 pub header: Option<String>,
197 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
199 pub default_owners: Option<Vec<String>>,
200 #[cfg_attr(feature = "serde", serde(default))]
202 pub rules: Vec<Rule>,
203}
204
205impl Codeowners {
206 #[must_use]
218 pub fn builder() -> CodeownersBuilder {
219 CodeownersBuilder::default()
220 }
221
222 #[must_use]
237 pub fn generate(&self) -> String {
238 let mut output = String::new();
239 let platform = self.platform.unwrap_or_default();
240
241 if let Some(ref header) = self.header {
243 for line in header.lines() {
244 output.push_str("# ");
245 output.push_str(line);
246 output.push('\n');
247 }
248 output.push('\n');
249 }
250
251 if let Some(ref default_owners) = self.default_owners
253 && !default_owners.is_empty()
254 {
255 output.push_str("# Default owners for all files\n");
256 output.push_str("* ");
257 output.push_str(&default_owners.join(" "));
258 output.push('\n');
259 output.push('\n');
260 }
261
262 let mut rules_by_section: BTreeMap<Option<&str>, Vec<&Rule>> = BTreeMap::new();
264 for rule in &self.rules {
265 rules_by_section
266 .entry(rule.section.as_deref())
267 .or_default()
268 .push(rule);
269 }
270
271 let mut first_section = true;
272 for (section, rules) in rules_by_section {
273 if !first_section {
274 output.push('\n');
275 }
276 first_section = false;
277
278 if let Some(section_name) = section {
280 match platform {
281 Platform::Gitlab => {
282 output.push('[');
283 output.push_str(section_name);
284 output.push_str("]\n");
285 }
286 Platform::Github | Platform::Bitbucket => {
287 output.push_str("# ");
288 output.push_str(section_name);
289 output.push('\n');
290 }
291 }
292 }
293
294 for rule in rules {
296 if let Some(ref description) = rule.description {
297 output.push_str("# ");
298 output.push_str(description);
299 output.push('\n');
300 }
301
302 output.push_str(&rule.pattern);
303 output.push(' ');
304 output.push_str(&rule.owners.join(" "));
305 output.push('\n');
306 }
307 }
308
309 output
310 }
311
312 #[must_use]
316 pub fn output_path(&self) -> &str {
317 self.path
318 .as_deref()
319 .unwrap_or_else(|| self.platform.unwrap_or_default().default_path())
320 }
321
322 #[must_use]
339 pub fn detect_platform(repo_root: &Path) -> Platform {
340 if repo_root.join(".github").is_dir() {
341 Platform::Github
342 } else if repo_root.join(".gitlab-ci.yml").exists() {
343 Platform::Gitlab
344 } else if repo_root.join("bitbucket-pipelines.yml").exists() {
345 Platform::Bitbucket
346 } else {
347 Platform::Github
348 }
349 }
350}
351
352#[derive(Debug, Clone, Default)]
371pub struct CodeownersBuilder {
372 platform: Option<Platform>,
373 path: Option<String>,
374 header: Option<String>,
375 default_owners: Option<Vec<String>>,
376 rules: Vec<Rule>,
377}
378
379impl CodeownersBuilder {
380 #[must_use]
382 pub fn platform(mut self, platform: Platform) -> Self {
383 self.platform = Some(platform);
384 self
385 }
386
387 #[must_use]
391 pub fn path(mut self, path: impl Into<String>) -> Self {
392 self.path = Some(path.into());
393 self
394 }
395
396 #[must_use]
400 pub fn header(mut self, header: impl Into<String>) -> Self {
401 self.header = Some(header.into());
402 self
403 }
404
405 #[must_use]
409 pub fn default_owners(mut self, owners: impl IntoIterator<Item = impl Into<String>>) -> Self {
410 self.default_owners = Some(owners.into_iter().map(Into::into).collect());
411 self
412 }
413
414 #[must_use]
416 pub fn rule(mut self, rule: Rule) -> Self {
417 self.rules.push(rule);
418 self
419 }
420
421 #[must_use]
423 pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
424 self.rules.extend(rules);
425 self
426 }
427
428 #[must_use]
430 pub fn build(self) -> Codeowners {
431 Codeowners {
432 platform: self.platform,
433 path: self.path,
434 header: self.header,
435 default_owners: self.default_owners,
436 rules: self.rules,
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn test_platform_default_paths() {
447 assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
448 assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
449 assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
450 }
451
452 #[test]
453 fn test_platform_display() {
454 assert_eq!(Platform::Github.to_string(), "github");
455 assert_eq!(Platform::Gitlab.to_string(), "gitlab");
456 assert_eq!(Platform::Bitbucket.to_string(), "bitbucket");
457 }
458
459 #[test]
460 fn test_rule_builder() {
461 let rule = Rule::new("*.rs", ["@rust-team"])
462 .description("Rust files")
463 .section("Backend");
464
465 assert_eq!(rule.pattern, "*.rs");
466 assert_eq!(rule.owners, vec!["@rust-team"]);
467 assert_eq!(rule.description, Some("Rust files".to_string()));
468 assert_eq!(rule.section, Some("Backend".to_string()));
469 }
470
471 #[test]
472 fn test_codeowners_output_path() {
473 let codeowners = Codeowners::builder().build();
475 assert_eq!(codeowners.output_path(), ".github/CODEOWNERS");
476
477 let codeowners = Codeowners::builder().platform(Platform::Gitlab).build();
479 assert_eq!(codeowners.output_path(), "CODEOWNERS");
480
481 let codeowners = Codeowners::builder()
483 .platform(Platform::Github)
484 .path("docs/CODEOWNERS")
485 .build();
486 assert_eq!(codeowners.output_path(), "docs/CODEOWNERS");
487 }
488
489 #[test]
490 fn test_generate_simple() {
491 let codeowners = Codeowners::builder()
492 .rule(Rule::new("*.rs", ["@rust-team"]))
493 .rule(Rule::new("/docs/**", ["@docs-team", "@tech-writers"]))
494 .build();
495
496 let content = codeowners.generate();
497 assert!(content.contains("*.rs @rust-team"));
498 assert!(content.contains("/docs/** @docs-team @tech-writers"));
499 }
500
501 #[test]
502 fn test_generate_with_sections() {
503 let codeowners = Codeowners::builder()
504 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
505 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
506 .build();
507
508 let content = codeowners.generate();
509 assert!(content.contains("# Backend"));
510 assert!(content.contains("# Frontend"));
511 }
512
513 #[test]
514 fn test_generate_with_default_owners() {
515 let codeowners = Codeowners::builder()
516 .default_owners(["@core-team"])
517 .rule(Rule::new("/security/**", ["@security-team"]))
518 .build();
519
520 let content = codeowners.generate();
521 assert!(content.contains("* @core-team"));
522 assert!(content.contains("/security/** @security-team"));
523 }
524
525 #[test]
526 fn test_generate_with_custom_header() {
527 let codeowners = Codeowners::builder()
528 .header("Custom Header\nLine 2")
529 .build();
530
531 let content = codeowners.generate();
532 assert!(content.contains("# Custom Header"));
533 assert!(content.contains("# Line 2"));
534 }
535
536 #[test]
537 fn test_generate_with_descriptions() {
538 let codeowners = Codeowners::builder()
539 .rule(Rule::new("*.rs", ["@rust-team"]).description("Rust source files"))
540 .build();
541
542 let content = codeowners.generate();
543 assert!(content.contains("# Rust source files"));
544 assert!(content.contains("*.rs @rust-team"));
545 }
546
547 #[test]
548 fn test_generate_gitlab_sections() {
549 let codeowners = Codeowners::builder()
550 .platform(Platform::Gitlab)
551 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
552 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
553 .build();
554
555 let content = codeowners.generate();
556 assert!(
558 content.contains("[Backend]"),
559 "GitLab should use [Section] syntax, got: {content}"
560 );
561 assert!(
562 content.contains("[Frontend]"),
563 "GitLab should use [Section] syntax, got: {content}"
564 );
565 assert!(
567 !content.contains("# Backend"),
568 "GitLab should NOT use # Section"
569 );
570 assert!(
571 !content.contains("# Frontend"),
572 "GitLab should NOT use # Section"
573 );
574 }
575
576 #[test]
577 fn test_generate_groups_rules_by_section() {
578 let codeowners = Codeowners::builder()
580 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
581 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
582 .rule(Rule::new("*.go", ["@backend"]).section("Backend"))
583 .build();
584
585 let content = codeowners.generate();
586
587 let backend_count = content.matches("# Backend").count();
589 assert_eq!(
590 backend_count, 1,
591 "Backend section should appear exactly once, found {backend_count} times"
592 );
593
594 let backend_idx = content.find("# Backend").unwrap();
596 let rs_idx = content.find("*.rs").unwrap();
597 let go_idx = content.find("*.go").unwrap();
598 let frontend_idx = content.find("# Frontend").unwrap();
599
600 assert!(
601 rs_idx > backend_idx && rs_idx < frontend_idx,
602 "*.rs should be in Backend section"
603 );
604 assert!(
605 go_idx > backend_idx && go_idx < frontend_idx,
606 "*.go should be in Backend section"
607 );
608 }
609
610 #[test]
611 fn test_builder_chaining() {
612 let codeowners = Codeowners::builder()
613 .platform(Platform::Github)
614 .path(".github/CODEOWNERS")
615 .header("Code ownership")
616 .default_owners(["@org/maintainers"])
617 .rule(Rule::new("*.rs", ["@rust"]))
618 .rules([
619 Rule::new("*.ts", ["@typescript"]),
620 Rule::new("*.py", ["@python"]),
621 ])
622 .build();
623
624 assert_eq!(codeowners.platform, Some(Platform::Github));
625 assert_eq!(codeowners.path, Some(".github/CODEOWNERS".to_string()));
626 assert_eq!(codeowners.header, Some("Code ownership".to_string()));
627 assert_eq!(
628 codeowners.default_owners,
629 Some(vec!["@org/maintainers".to_string()])
630 );
631 assert_eq!(codeowners.rules.len(), 3);
632 }
633}