1#![warn(missing_docs)]
40
41pub mod provider;
42
43use std::collections::BTreeMap;
44use std::fmt;
45use std::path::Path;
46
47#[cfg(feature = "schemars")]
48use schemars::JsonSchema;
49#[cfg(feature = "serde")]
50use serde::{Deserialize, Serialize};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
60#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
61#[cfg_attr(feature = "schemars", derive(JsonSchema))]
62pub enum Platform {
63 #[default]
65 Github,
66 Gitlab,
68 Bitbucket,
70}
71
72impl Platform {
73 #[must_use]
84 pub fn default_path(&self) -> &'static str {
85 match self {
86 Platform::Github => ".github/CODEOWNERS",
87 Platform::Gitlab | Platform::Bitbucket => "CODEOWNERS",
88 }
89 }
90}
91
92impl fmt::Display for Platform {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 match self {
95 Platform::Github => write!(f, "github"),
96 Platform::Gitlab => write!(f, "gitlab"),
97 Platform::Bitbucket => write!(f, "bitbucket"),
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
116#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
117#[cfg_attr(feature = "schemars", derive(JsonSchema))]
118pub struct Rule {
119 pub pattern: String,
121 pub owners: Vec<String>,
123 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
125 pub description: Option<String>,
126 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
128 pub section: Option<String>,
129}
130
131impl Rule {
132 pub fn new(
143 pattern: impl Into<String>,
144 owners: impl IntoIterator<Item = impl Into<String>>,
145 ) -> Self {
146 Self {
147 pattern: pattern.into(),
148 owners: owners.into_iter().map(Into::into).collect(),
149 description: None,
150 section: None,
151 }
152 }
153
154 #[must_use]
158 pub fn description(mut self, description: impl Into<String>) -> Self {
159 self.description = Some(description.into());
160 self
161 }
162
163 #[must_use]
167 pub fn section(mut self, section: impl Into<String>) -> Self {
168 self.section = Some(section.into());
169 self
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Default)]
192#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
193#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
194#[cfg_attr(feature = "schemars", derive(JsonSchema))]
195pub struct Codeowners {
196 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
198 pub platform: Option<Platform>,
199 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
201 pub path: Option<String>,
202 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
204 pub header: Option<String>,
205 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
207 pub default_owners: Option<Vec<String>>,
208 #[cfg_attr(feature = "serde", serde(default))]
210 pub rules: Vec<Rule>,
211}
212
213impl Codeowners {
214 #[must_use]
226 pub fn builder() -> CodeownersBuilder {
227 CodeownersBuilder::default()
228 }
229
230 #[must_use]
245 pub fn generate(&self) -> String {
246 let mut output = String::new();
247 let platform = self.platform.unwrap_or_default();
248
249 if let Some(ref header) = self.header {
251 for line in header.lines() {
252 output.push_str("# ");
253 output.push_str(line);
254 output.push('\n');
255 }
256 output.push('\n');
257 }
258
259 if let Some(ref default_owners) = self.default_owners
261 && !default_owners.is_empty()
262 {
263 output.push_str("# Default owners for all files\n");
264 output.push_str("* ");
265 output.push_str(&default_owners.join(" "));
266 output.push('\n');
267 output.push('\n');
268 }
269
270 let mut rules_by_section: BTreeMap<Option<&str>, Vec<&Rule>> = BTreeMap::new();
272 for rule in &self.rules {
273 rules_by_section
274 .entry(rule.section.as_deref())
275 .or_default()
276 .push(rule);
277 }
278
279 let mut first_section = true;
280 for (section, rules) in rules_by_section {
281 if !first_section {
282 output.push('\n');
283 }
284 first_section = false;
285
286 if let Some(section_name) = section {
288 match platform {
289 Platform::Gitlab => {
290 output.push('[');
291 output.push_str(section_name);
292 output.push_str("]\n");
293 }
294 Platform::Github | Platform::Bitbucket => {
295 output.push_str("# ");
296 output.push_str(section_name);
297 output.push('\n');
298 }
299 }
300 }
301
302 for rule in rules {
304 if let Some(ref description) = rule.description {
305 output.push_str("# ");
306 output.push_str(description);
307 output.push('\n');
308 }
309
310 output.push_str(&rule.pattern);
311 output.push(' ');
312 output.push_str(&rule.owners.join(" "));
313 output.push('\n');
314 }
315 }
316
317 output
318 }
319
320 #[must_use]
324 pub fn output_path(&self) -> &str {
325 self.path
326 .as_deref()
327 .unwrap_or_else(|| self.platform.unwrap_or_default().default_path())
328 }
329
330 #[must_use]
347 pub fn detect_platform(repo_root: &Path) -> Platform {
348 if repo_root.join(".github").is_dir() {
349 Platform::Github
350 } else if repo_root.join(".gitlab-ci.yml").exists() {
351 Platform::Gitlab
352 } else if repo_root.join("bitbucket-pipelines.yml").exists() {
353 Platform::Bitbucket
354 } else {
355 Platform::Github
356 }
357 }
358}
359
360#[derive(Debug, Clone, Default)]
379pub struct CodeownersBuilder {
380 platform: Option<Platform>,
381 path: Option<String>,
382 header: Option<String>,
383 default_owners: Option<Vec<String>>,
384 rules: Vec<Rule>,
385}
386
387impl CodeownersBuilder {
388 #[must_use]
390 pub fn platform(mut self, platform: Platform) -> Self {
391 self.platform = Some(platform);
392 self
393 }
394
395 #[must_use]
399 pub fn path(mut self, path: impl Into<String>) -> Self {
400 self.path = Some(path.into());
401 self
402 }
403
404 #[must_use]
408 pub fn header(mut self, header: impl Into<String>) -> Self {
409 self.header = Some(header.into());
410 self
411 }
412
413 #[must_use]
417 pub fn default_owners(mut self, owners: impl IntoIterator<Item = impl Into<String>>) -> Self {
418 self.default_owners = Some(owners.into_iter().map(Into::into).collect());
419 self
420 }
421
422 #[must_use]
424 pub fn rule(mut self, rule: Rule) -> Self {
425 self.rules.push(rule);
426 self
427 }
428
429 #[must_use]
431 pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
432 self.rules.extend(rules);
433 self
434 }
435
436 #[must_use]
438 pub fn build(self) -> Codeowners {
439 Codeowners {
440 platform: self.platform,
441 path: self.path,
442 header: self.header,
443 default_owners: self.default_owners,
444 rules: self.rules,
445 }
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn test_platform_default_paths() {
455 assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
456 assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
457 assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
458 }
459
460 #[test]
461 fn test_platform_display() {
462 assert_eq!(Platform::Github.to_string(), "github");
463 assert_eq!(Platform::Gitlab.to_string(), "gitlab");
464 assert_eq!(Platform::Bitbucket.to_string(), "bitbucket");
465 }
466
467 #[test]
468 fn test_rule_builder() {
469 let rule = Rule::new("*.rs", ["@rust-team"])
470 .description("Rust files")
471 .section("Backend");
472
473 assert_eq!(rule.pattern, "*.rs");
474 assert_eq!(rule.owners, vec!["@rust-team"]);
475 assert_eq!(rule.description, Some("Rust files".to_string()));
476 assert_eq!(rule.section, Some("Backend".to_string()));
477 }
478
479 #[test]
480 fn test_codeowners_output_path() {
481 let codeowners = Codeowners::builder().build();
483 assert_eq!(codeowners.output_path(), ".github/CODEOWNERS");
484
485 let codeowners = Codeowners::builder().platform(Platform::Gitlab).build();
487 assert_eq!(codeowners.output_path(), "CODEOWNERS");
488
489 let codeowners = Codeowners::builder()
491 .platform(Platform::Github)
492 .path("docs/CODEOWNERS")
493 .build();
494 assert_eq!(codeowners.output_path(), "docs/CODEOWNERS");
495 }
496
497 #[test]
498 fn test_generate_simple() {
499 let codeowners = Codeowners::builder()
500 .rule(Rule::new("*.rs", ["@rust-team"]))
501 .rule(Rule::new("/docs/**", ["@docs-team", "@tech-writers"]))
502 .build();
503
504 let content = codeowners.generate();
505 assert!(content.contains("*.rs @rust-team"));
506 assert!(content.contains("/docs/** @docs-team @tech-writers"));
507 }
508
509 #[test]
510 fn test_generate_with_sections() {
511 let codeowners = Codeowners::builder()
512 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
513 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
514 .build();
515
516 let content = codeowners.generate();
517 assert!(content.contains("# Backend"));
518 assert!(content.contains("# Frontend"));
519 }
520
521 #[test]
522 fn test_generate_with_default_owners() {
523 let codeowners = Codeowners::builder()
524 .default_owners(["@core-team"])
525 .rule(Rule::new("/security/**", ["@security-team"]))
526 .build();
527
528 let content = codeowners.generate();
529 assert!(content.contains("* @core-team"));
530 assert!(content.contains("/security/** @security-team"));
531 }
532
533 #[test]
534 fn test_generate_with_custom_header() {
535 let codeowners = Codeowners::builder()
536 .header("Custom Header\nLine 2")
537 .build();
538
539 let content = codeowners.generate();
540 assert!(content.contains("# Custom Header"));
541 assert!(content.contains("# Line 2"));
542 }
543
544 #[test]
545 fn test_generate_with_descriptions() {
546 let codeowners = Codeowners::builder()
547 .rule(Rule::new("*.rs", ["@rust-team"]).description("Rust source files"))
548 .build();
549
550 let content = codeowners.generate();
551 assert!(content.contains("# Rust source files"));
552 assert!(content.contains("*.rs @rust-team"));
553 }
554
555 #[test]
556 fn test_generate_gitlab_sections() {
557 let codeowners = Codeowners::builder()
558 .platform(Platform::Gitlab)
559 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
560 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
561 .build();
562
563 let content = codeowners.generate();
564 assert!(
566 content.contains("[Backend]"),
567 "GitLab should use [Section] syntax, got: {content}"
568 );
569 assert!(
570 content.contains("[Frontend]"),
571 "GitLab should use [Section] syntax, got: {content}"
572 );
573 assert!(
575 !content.contains("# Backend"),
576 "GitLab should NOT use # Section"
577 );
578 assert!(
579 !content.contains("# Frontend"),
580 "GitLab should NOT use # Section"
581 );
582 }
583
584 #[test]
585 fn test_generate_groups_rules_by_section() {
586 let codeowners = Codeowners::builder()
588 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
589 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
590 .rule(Rule::new("*.go", ["@backend"]).section("Backend"))
591 .build();
592
593 let content = codeowners.generate();
594
595 let backend_count = content.matches("# Backend").count();
597 assert_eq!(
598 backend_count, 1,
599 "Backend section should appear exactly once, found {backend_count} times"
600 );
601
602 let backend_idx = content.find("# Backend").unwrap();
604 let rs_idx = content.find("*.rs").unwrap();
605 let go_idx = content.find("*.go").unwrap();
606 let frontend_idx = content.find("# Frontend").unwrap();
607
608 assert!(
609 rs_idx > backend_idx && rs_idx < frontend_idx,
610 "*.rs should be in Backend section"
611 );
612 assert!(
613 go_idx > backend_idx && go_idx < frontend_idx,
614 "*.go should be in Backend section"
615 );
616 }
617
618 #[test]
619 fn test_builder_chaining() {
620 let codeowners = Codeowners::builder()
621 .platform(Platform::Github)
622 .path(".github/CODEOWNERS")
623 .header("Code ownership")
624 .default_owners(["@org/maintainers"])
625 .rule(Rule::new("*.rs", ["@rust"]))
626 .rules([
627 Rule::new("*.ts", ["@typescript"]),
628 Rule::new("*.py", ["@python"]),
629 ])
630 .build();
631
632 assert_eq!(codeowners.platform, Some(Platform::Github));
633 assert_eq!(codeowners.path, Some(".github/CODEOWNERS".to_string()));
634 assert_eq!(codeowners.header, Some("Code ownership".to_string()));
635 assert_eq!(
636 codeowners.default_owners,
637 Some(vec!["@org/maintainers".to_string()])
638 );
639 assert_eq!(codeowners.rules.len(), 3);
640 }
641}