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//!     .default_owners(["@org/core-team"])
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///     .default_owners(["@org/maintainers"])
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    /// Default owners applied to all files (`*` rule).
206    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
207    pub default_owners: Option<Vec<String>>,
208    /// Ownership rules.
209    #[cfg_attr(feature = "serde", serde(default))]
210    pub rules: Vec<Rule>,
211}
212
213impl Codeowners {
214    /// Create a new builder for constructing a Codeowners configuration.
215    ///
216    /// # Example
217    ///
218    /// ```rust
219    /// use cuenv_codeowners::Codeowners;
220    ///
221    /// let codeowners = Codeowners::builder()
222    ///     .rule(cuenv_codeowners::Rule::new("*", ["@fallback-team"]))
223    ///     .build();
224    /// ```
225    #[must_use]
226    pub fn builder() -> CodeownersBuilder {
227        CodeownersBuilder::default()
228    }
229
230    /// Generate the CODEOWNERS file content.
231    ///
232    /// # Example
233    ///
234    /// ```rust
235    /// use cuenv_codeowners::{Codeowners, Rule};
236    ///
237    /// let codeowners = Codeowners::builder()
238    ///     .rule(Rule::new("*.rs", ["@rust-team"]))
239    ///     .build();
240    ///
241    /// let content = codeowners.generate();
242    /// assert!(content.contains("*.rs @rust-team"));
243    /// ```
244    #[must_use]
245    pub fn generate(&self) -> String {
246        let mut output = String::new();
247        let platform = self.platform.unwrap_or_default();
248
249        // Add header
250        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        // Add default owners if any
260        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        // Group rules by section for contiguous output
271        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            // Write section header if present
287            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            // Write all rules in this section
303            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    /// Get the output path for the CODEOWNERS file.
321    ///
322    /// Returns the custom path if set, otherwise the platform's default path.
323    #[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    /// Detect the platform from repository structure.
331    ///
332    /// Checks for platform-specific files/directories:
333    /// - `.github/` directory -> GitHub
334    /// - `.gitlab-ci.yml` file -> GitLab
335    /// - `bitbucket-pipelines.yml` file -> Bitbucket
336    /// - Falls back to GitHub if none detected
337    ///
338    /// # Example
339    ///
340    /// ```rust
341    /// use cuenv_codeowners::{Codeowners, Platform};
342    /// use std::path::Path;
343    ///
344    /// let platform = Codeowners::detect_platform(Path::new("."));
345    /// ```
346    #[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/// Builder for [`Codeowners`].
361///
362/// # Example
363///
364/// ```rust
365/// use cuenv_codeowners::{Codeowners, Platform, Rule};
366///
367/// let codeowners = Codeowners::builder()
368///     .platform(Platform::Github)
369///     .header("Code ownership rules")
370///     .default_owners(["@org/maintainers"])
371///     .rule(Rule::new("*.rs", ["@rust-team"]))
372///     .rules([
373///         Rule::new("/docs/**", ["@docs-team"]),
374///         Rule::new("/ci/**", ["@devops"]),
375///     ])
376///     .build();
377/// ```
378#[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    /// Set the target platform.
389    #[must_use]
390    pub fn platform(mut self, platform: Platform) -> Self {
391        self.platform = Some(platform);
392        self
393    }
394
395    /// Set a custom output path.
396    ///
397    /// Overrides the platform's default path.
398    #[must_use]
399    pub fn path(mut self, path: impl Into<String>) -> Self {
400        self.path = Some(path.into());
401        self
402    }
403
404    /// Set a custom header comment.
405    ///
406    /// The header will be added at the top of the file with `#` prefixes.
407    #[must_use]
408    pub fn header(mut self, header: impl Into<String>) -> Self {
409        self.header = Some(header.into());
410        self
411    }
412
413    /// Set default owners for all files.
414    ///
415    /// This creates a `* @owner1 @owner2` rule at the top of the file.
416    #[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    /// Add a single rule.
423    #[must_use]
424    pub fn rule(mut self, rule: Rule) -> Self {
425        self.rules.push(rule);
426        self
427    }
428
429    /// Add multiple rules.
430    #[must_use]
431    pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
432        self.rules.extend(rules);
433        self
434    }
435
436    /// Build the [`Codeowners`] configuration.
437    #[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        // Default (no config)
482        let codeowners = Codeowners::builder().build();
483        assert_eq!(codeowners.output_path(), ".github/CODEOWNERS");
484
485        // With platform
486        let codeowners = Codeowners::builder().platform(Platform::Gitlab).build();
487        assert_eq!(codeowners.output_path(), "CODEOWNERS");
488
489        // With custom path
490        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        // GitLab uses [Section] syntax
565        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        // Should NOT use comment-style sections
574        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        // Rules with same section should be grouped even if not contiguous in input
587        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        // Backend section should only appear once
596        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        // Both backend rules should be together
603        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}