cuenv_codeowners/
lib.rs

1//! Generate CODEOWNERS files for GitHub, GitLab, and Bitbucket.
2//!
3//! This crate provides a builder-based API for generating CODEOWNERS files
4//! that define code ownership rules for your repository.
5//!
6//! # Example
7//!
8//! ```rust
9//! use cuenv_codeowners::{Codeowners, Platform, Rule};
10//!
11//! let codeowners = Codeowners::builder()
12//!     .platform(Platform::Github)
13//!     .rule(Rule::new("*", ["@org/core-team"]))  // Catch-all rule
14//!     .rule(Rule::new("*.rs", ["@rust-team"]))
15//!     .rule(Rule::new("/docs/**", ["@docs-team"]).section("Documentation"))
16//!     .build();
17//!
18//! let content = codeowners.generate();
19//! // Write to .github/CODEOWNERS or wherever appropriate
20//! ```
21//!
22//! # Platform Support
23//!
24//! - **GitHub**: Uses `.github/CODEOWNERS` path and `# Section` comment syntax
25//! - **GitLab**: Uses `CODEOWNERS` path and `[Section]` syntax for sections
26//! - **Bitbucket**: Uses `CODEOWNERS` path and `# Section` comment syntax
27//!
28//! # Provider Support
29//!
30//! The [`provider`] module provides a trait-based abstraction for syncing
31//! CODEOWNERS files. Use [`provider::detect_provider`] to auto-detect the
32//! platform and get the appropriate provider.
33//!
34//! # Features
35//!
36//! - `serde`: Enable serde serialization/deserialization for all types
37//! - `schemars`: Enable JSON Schema generation (implies `serde`)
38
39#![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/// Target platform for CODEOWNERS file generation.
53///
54/// Different platforms have different default paths and section syntax:
55/// - GitHub: `.github/CODEOWNERS`, sections as `# Section Name`
56/// - GitLab: `CODEOWNERS`, sections as `[Section Name]`
57/// - Bitbucket: `CODEOWNERS`, sections as `# Section Name`
58#[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    /// GitHub - uses `.github/CODEOWNERS` and `# Section` comments
64    #[default]
65    Github,
66    /// GitLab - uses `CODEOWNERS` and `[Section]` syntax
67    Gitlab,
68    /// Bitbucket - uses `CODEOWNERS` and `# Section` comments
69    Bitbucket,
70}
71
72impl Platform {
73    /// Get the default path for CODEOWNERS file on this platform.
74    ///
75    /// # Example
76    ///
77    /// ```rust
78    /// use cuenv_codeowners::Platform;
79    ///
80    /// assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
81    /// assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
82    /// ```
83    #[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/// A single code ownership rule.
103///
104/// Each rule maps a file pattern to one or more owners.
105///
106/// # Example
107///
108/// ```rust
109/// use cuenv_codeowners::Rule;
110///
111/// let rule = Rule::new("*.rs", ["@rust-team", "@backend"])
112///     .description("Rust source files")
113///     .section("Backend");
114/// ```
115#[derive(Debug, Clone, PartialEq, Eq)]
116#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
117#[cfg_attr(feature = "schemars", derive(JsonSchema))]
118pub struct Rule {
119    /// File pattern (glob syntax) matching files this rule applies to.
120    pub pattern: String,
121    /// List of owners for files matching this pattern.
122    pub owners: Vec<String>,
123    /// Optional description added as a comment above the rule.
124    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
125    pub description: Option<String>,
126    /// Optional section name for grouping rules in the output.
127    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
128    pub section: Option<String>,
129}
130
131impl Rule {
132    /// Create a new rule with a pattern and owners.
133    ///
134    /// # Example
135    ///
136    /// ```rust
137    /// use cuenv_codeowners::Rule;
138    ///
139    /// let rule = Rule::new("*.rs", ["@rust-team"]);
140    /// let rule = Rule::new("/docs/**", vec!["@docs-team", "@tech-writers"]);
141    /// ```
142    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    /// Add a description to this rule.
155    ///
156    /// The description will be added as a comment above the rule in the output.
157    #[must_use]
158    pub fn description(mut self, description: impl Into<String>) -> Self {
159        self.description = Some(description.into());
160        self
161    }
162
163    /// Assign this rule to a section.
164    ///
165    /// Rules with the same section will be grouped together in the output.
166    #[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/// CODEOWNERS file configuration and generator.
174///
175/// Use [`Codeowners::builder()`] to create a new instance.
176///
177/// # Example
178///
179/// ```rust
180/// use cuenv_codeowners::{Codeowners, Platform, Rule};
181///
182/// let codeowners = Codeowners::builder()
183///     .platform(Platform::Github)
184///     .header("Custom header comment")
185///     .rule(Rule::new("*", ["@org/maintainers"]))  // Catch-all rule
186///     .rule(Rule::new("*.rs", ["@rust-team"]))
187///     .build();
188///
189/// println!("{}", codeowners.generate());
190/// ```
191#[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    /// Target platform for the CODEOWNERS file.
197    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
198    pub platform: Option<Platform>,
199    /// Custom output path override.
200    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
201    pub path: Option<String>,
202    /// Custom header comment for the file.
203    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
204    pub header: Option<String>,
205    /// Ownership rules.
206    #[cfg_attr(feature = "serde", serde(default))]
207    pub rules: Vec<Rule>,
208}
209
210impl Codeowners {
211    /// Create a new builder for constructing a Codeowners configuration.
212    ///
213    /// # Example
214    ///
215    /// ```rust
216    /// use cuenv_codeowners::Codeowners;
217    ///
218    /// let codeowners = Codeowners::builder()
219    ///     .rule(cuenv_codeowners::Rule::new("*", ["@fallback-team"]))
220    ///     .build();
221    /// ```
222    #[must_use]
223    pub fn builder() -> CodeownersBuilder {
224        CodeownersBuilder::default()
225    }
226
227    /// Generate the CODEOWNERS file content.
228    ///
229    /// # Example
230    ///
231    /// ```rust
232    /// use cuenv_codeowners::{Codeowners, Rule};
233    ///
234    /// let codeowners = Codeowners::builder()
235    ///     .rule(Rule::new("*.rs", ["@rust-team"]))
236    ///     .build();
237    ///
238    /// let content = codeowners.generate();
239    /// assert!(content.contains("*.rs @rust-team"));
240    /// ```
241    #[must_use]
242    pub fn generate(&self) -> String {
243        let mut output = String::new();
244        let platform = self.platform.unwrap_or_default();
245
246        // Add header
247        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        // Group rules by section for contiguous output
257        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            // Write section header if present
273            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            // Write all rules in this section
289            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    /// Get the output path for the CODEOWNERS file.
307    ///
308    /// Returns the custom path if set, otherwise the platform's default path.
309    #[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    /// Detect the platform from repository structure.
317    ///
318    /// Checks for platform-specific files/directories:
319    /// - `.github/` directory -> GitHub
320    /// - `.gitlab-ci.yml` file -> GitLab
321    /// - `bitbucket-pipelines.yml` file -> Bitbucket
322    /// - Falls back to GitHub if none detected
323    ///
324    /// # Example
325    ///
326    /// ```rust
327    /// use cuenv_codeowners::{Codeowners, Platform};
328    /// use std::path::Path;
329    ///
330    /// let platform = Codeowners::detect_platform(Path::new("."));
331    /// ```
332    #[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/// Builder for [`Codeowners`].
347///
348/// # Example
349///
350/// ```rust
351/// use cuenv_codeowners::{Codeowners, Platform, Rule};
352///
353/// let codeowners = Codeowners::builder()
354///     .platform(Platform::Github)
355///     .header("Code ownership rules")
356///     .rule(Rule::new("*", ["@org/maintainers"]))  // Catch-all rule
357///     .rule(Rule::new("*.rs", ["@rust-team"]))
358///     .rules([
359///         Rule::new("/docs/**", ["@docs-team"]),
360///         Rule::new("/ci/**", ["@devops"]),
361///     ])
362///     .build();
363/// ```
364#[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    /// Set the target platform.
374    #[must_use]
375    pub fn platform(mut self, platform: Platform) -> Self {
376        self.platform = Some(platform);
377        self
378    }
379
380    /// Set a custom output path.
381    ///
382    /// Overrides the platform's default path.
383    #[must_use]
384    pub fn path(mut self, path: impl Into<String>) -> Self {
385        self.path = Some(path.into());
386        self
387    }
388
389    /// Set a custom header comment.
390    ///
391    /// The header will be added at the top of the file with `#` prefixes.
392    #[must_use]
393    pub fn header(mut self, header: impl Into<String>) -> Self {
394        self.header = Some(header.into());
395        self
396    }
397
398    /// Add a single rule.
399    #[must_use]
400    pub fn rule(mut self, rule: Rule) -> Self {
401        self.rules.push(rule);
402        self
403    }
404
405    /// Add multiple rules.
406    #[must_use]
407    pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
408        self.rules.extend(rules);
409        self
410    }
411
412    /// Build the [`Codeowners`] configuration.
413    #[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        // Default (no config)
457        let codeowners = Codeowners::builder().build();
458        assert_eq!(codeowners.output_path(), ".github/CODEOWNERS");
459
460        // With platform
461        let codeowners = Codeowners::builder().platform(Platform::Gitlab).build();
462        assert_eq!(codeowners.output_path(), "CODEOWNERS");
463
464        // With custom path
465        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        // GitLab uses [Section] syntax
528        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        // Should NOT use comment-style sections
537        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        // Rules with same section should be grouped even if not contiguous in input
550        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        // Backend section should only appear once
559        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        // Both backend rules should be together
566        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}