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(default))]
207 pub rules: Vec<Rule>,
208}
209
210impl Codeowners {
211 #[must_use]
223 pub fn builder() -> CodeownersBuilder {
224 CodeownersBuilder::default()
225 }
226
227 #[must_use]
242 pub fn generate(&self) -> String {
243 let mut output = String::new();
244 let platform = self.platform.unwrap_or_default();
245
246 if let Some(ref header) = self.header {
248 for line in header.lines() {
249 output.push_str("# ");
250 output.push_str(line);
251 output.push('\n');
252 }
253 output.push('\n');
254 }
255
256 let mut rules_by_section: BTreeMap<Option<&str>, Vec<&Rule>> = BTreeMap::new();
258 for rule in &self.rules {
259 rules_by_section
260 .entry(rule.section.as_deref())
261 .or_default()
262 .push(rule);
263 }
264
265 let mut first_section = true;
266 for (section, rules) in rules_by_section {
267 if !first_section {
268 output.push('\n');
269 }
270 first_section = false;
271
272 if let Some(section_name) = section {
274 match platform {
275 Platform::Gitlab => {
276 output.push('[');
277 output.push_str(section_name);
278 output.push_str("]\n");
279 }
280 Platform::Github | Platform::Bitbucket => {
281 output.push_str("# ");
282 output.push_str(section_name);
283 output.push('\n');
284 }
285 }
286 }
287
288 for rule in rules {
290 if let Some(ref description) = rule.description {
291 output.push_str("# ");
292 output.push_str(description);
293 output.push('\n');
294 }
295
296 output.push_str(&rule.pattern);
297 output.push(' ');
298 output.push_str(&rule.owners.join(" "));
299 output.push('\n');
300 }
301 }
302
303 output
304 }
305
306 #[must_use]
310 pub fn output_path(&self) -> &str {
311 self.path
312 .as_deref()
313 .unwrap_or_else(|| self.platform.unwrap_or_default().default_path())
314 }
315
316 #[must_use]
333 pub fn detect_platform(repo_root: &Path) -> Platform {
334 if repo_root.join(".github").is_dir() {
335 Platform::Github
336 } else if repo_root.join(".gitlab-ci.yml").exists() {
337 Platform::Gitlab
338 } else if repo_root.join("bitbucket-pipelines.yml").exists() {
339 Platform::Bitbucket
340 } else {
341 Platform::Github
342 }
343 }
344}
345
346#[derive(Debug, Clone, Default)]
365pub struct CodeownersBuilder {
366 platform: Option<Platform>,
367 path: Option<String>,
368 header: Option<String>,
369 rules: Vec<Rule>,
370}
371
372impl CodeownersBuilder {
373 #[must_use]
375 pub fn platform(mut self, platform: Platform) -> Self {
376 self.platform = Some(platform);
377 self
378 }
379
380 #[must_use]
384 pub fn path(mut self, path: impl Into<String>) -> Self {
385 self.path = Some(path.into());
386 self
387 }
388
389 #[must_use]
393 pub fn header(mut self, header: impl Into<String>) -> Self {
394 self.header = Some(header.into());
395 self
396 }
397
398 #[must_use]
400 pub fn rule(mut self, rule: Rule) -> Self {
401 self.rules.push(rule);
402 self
403 }
404
405 #[must_use]
407 pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
408 self.rules.extend(rules);
409 self
410 }
411
412 #[must_use]
414 pub fn build(self) -> Codeowners {
415 Codeowners {
416 platform: self.platform,
417 path: self.path,
418 header: self.header,
419 rules: self.rules,
420 }
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_platform_default_paths() {
430 assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
431 assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
432 assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
433 }
434
435 #[test]
436 fn test_platform_display() {
437 assert_eq!(Platform::Github.to_string(), "github");
438 assert_eq!(Platform::Gitlab.to_string(), "gitlab");
439 assert_eq!(Platform::Bitbucket.to_string(), "bitbucket");
440 }
441
442 #[test]
443 fn test_rule_builder() {
444 let rule = Rule::new("*.rs", ["@rust-team"])
445 .description("Rust files")
446 .section("Backend");
447
448 assert_eq!(rule.pattern, "*.rs");
449 assert_eq!(rule.owners, vec!["@rust-team"]);
450 assert_eq!(rule.description, Some("Rust files".to_string()));
451 assert_eq!(rule.section, Some("Backend".to_string()));
452 }
453
454 #[test]
455 fn test_codeowners_output_path() {
456 let codeowners = Codeowners::builder().build();
458 assert_eq!(codeowners.output_path(), ".github/CODEOWNERS");
459
460 let codeowners = Codeowners::builder().platform(Platform::Gitlab).build();
462 assert_eq!(codeowners.output_path(), "CODEOWNERS");
463
464 let codeowners = Codeowners::builder()
466 .platform(Platform::Github)
467 .path("docs/CODEOWNERS")
468 .build();
469 assert_eq!(codeowners.output_path(), "docs/CODEOWNERS");
470 }
471
472 #[test]
473 fn test_generate_simple() {
474 let codeowners = Codeowners::builder()
475 .rule(Rule::new("*.rs", ["@rust-team"]))
476 .rule(Rule::new("/docs/**", ["@docs-team", "@tech-writers"]))
477 .build();
478
479 let content = codeowners.generate();
480 assert!(content.contains("*.rs @rust-team"));
481 assert!(content.contains("/docs/** @docs-team @tech-writers"));
482 }
483
484 #[test]
485 fn test_generate_with_sections() {
486 let codeowners = Codeowners::builder()
487 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
488 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
489 .build();
490
491 let content = codeowners.generate();
492 assert!(content.contains("# Backend"));
493 assert!(content.contains("# Frontend"));
494 }
495
496 #[test]
497 fn test_generate_with_custom_header() {
498 let codeowners = Codeowners::builder()
499 .header("Custom Header\nLine 2")
500 .build();
501
502 let content = codeowners.generate();
503 assert!(content.contains("# Custom Header"));
504 assert!(content.contains("# Line 2"));
505 }
506
507 #[test]
508 fn test_generate_with_descriptions() {
509 let codeowners = Codeowners::builder()
510 .rule(Rule::new("*.rs", ["@rust-team"]).description("Rust source files"))
511 .build();
512
513 let content = codeowners.generate();
514 assert!(content.contains("# Rust source files"));
515 assert!(content.contains("*.rs @rust-team"));
516 }
517
518 #[test]
519 fn test_generate_gitlab_sections() {
520 let codeowners = Codeowners::builder()
521 .platform(Platform::Gitlab)
522 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
523 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
524 .build();
525
526 let content = codeowners.generate();
527 assert!(
529 content.contains("[Backend]"),
530 "GitLab should use [Section] syntax, got: {content}"
531 );
532 assert!(
533 content.contains("[Frontend]"),
534 "GitLab should use [Section] syntax, got: {content}"
535 );
536 assert!(
538 !content.contains("# Backend"),
539 "GitLab should NOT use # Section"
540 );
541 assert!(
542 !content.contains("# Frontend"),
543 "GitLab should NOT use # Section"
544 );
545 }
546
547 #[test]
548 fn test_generate_groups_rules_by_section() {
549 let codeowners = Codeowners::builder()
551 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
552 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
553 .rule(Rule::new("*.go", ["@backend"]).section("Backend"))
554 .build();
555
556 let content = codeowners.generate();
557
558 let backend_count = content.matches("# Backend").count();
560 assert_eq!(
561 backend_count, 1,
562 "Backend section should appear exactly once, found {backend_count} times"
563 );
564
565 let backend_idx = content.find("# Backend").unwrap();
567 let rs_idx = content.find("*.rs").unwrap();
568 let go_idx = content.find("*.go").unwrap();
569 let frontend_idx = content.find("# Frontend").unwrap();
570
571 assert!(
572 rs_idx > backend_idx && rs_idx < frontend_idx,
573 "*.rs should be in Backend section"
574 );
575 assert!(
576 go_idx > backend_idx && go_idx < frontend_idx,
577 "*.go should be in Backend section"
578 );
579 }
580
581 #[test]
582 fn test_builder_chaining() {
583 let codeowners = Codeowners::builder()
584 .platform(Platform::Github)
585 .path(".github/CODEOWNERS")
586 .header("Code ownership")
587 .rule(Rule::new("*.rs", ["@rust"]))
588 .rules([
589 Rule::new("*.ts", ["@typescript"]),
590 Rule::new("*.py", ["@python"]),
591 ])
592 .build();
593
594 assert_eq!(codeowners.platform, Some(Platform::Github));
595 assert_eq!(codeowners.path, Some(".github/CODEOWNERS".to_string()));
596 assert_eq!(codeowners.header, Some("Code ownership".to_string()));
597 assert_eq!(codeowners.rules.len(), 3);
598 }
599}