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//! # Features
29//!
30//! - `serde`: Enable serde serialization/deserialization for all types
31//! - `schemars`: Enable JSON Schema generation (implies `serde`)
32
33#![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/// Target platform for CODEOWNERS file generation.
45///
46/// Different platforms have different default paths and section syntax:
47/// - GitHub: `.github/CODEOWNERS`, sections as `# Section Name`
48/// - GitLab: `CODEOWNERS`, sections as `[Section Name]`
49/// - Bitbucket: `CODEOWNERS`, sections as `# Section Name`
50#[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    /// GitHub - uses `.github/CODEOWNERS` and `# Section` comments
56    #[default]
57    Github,
58    /// GitLab - uses `CODEOWNERS` and `[Section]` syntax
59    Gitlab,
60    /// Bitbucket - uses `CODEOWNERS` and `# Section` comments
61    Bitbucket,
62}
63
64impl Platform {
65    /// Get the default path for CODEOWNERS file on this platform.
66    ///
67    /// # Example
68    ///
69    /// ```rust
70    /// use cuenv_codeowners::Platform;
71    ///
72    /// assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
73    /// assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
74    /// ```
75    #[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/// A single code ownership rule.
95///
96/// Each rule maps a file pattern to one or more owners.
97///
98/// # Example
99///
100/// ```rust
101/// use cuenv_codeowners::Rule;
102///
103/// let rule = Rule::new("*.rs", ["@rust-team", "@backend"])
104///     .description("Rust source files")
105///     .section("Backend");
106/// ```
107#[derive(Debug, Clone, PartialEq, Eq)]
108#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
109#[cfg_attr(feature = "schemars", derive(JsonSchema))]
110pub struct Rule {
111    /// File pattern (glob syntax) matching files this rule applies to.
112    pub pattern: String,
113    /// List of owners for files matching this pattern.
114    pub owners: Vec<String>,
115    /// Optional description added as a comment above the rule.
116    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
117    pub description: Option<String>,
118    /// Optional section name for grouping rules in the output.
119    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
120    pub section: Option<String>,
121}
122
123impl Rule {
124    /// Create a new rule with a pattern and owners.
125    ///
126    /// # Example
127    ///
128    /// ```rust
129    /// use cuenv_codeowners::Rule;
130    ///
131    /// let rule = Rule::new("*.rs", ["@rust-team"]);
132    /// let rule = Rule::new("/docs/**", vec!["@docs-team", "@tech-writers"]);
133    /// ```
134    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    /// Add a description to this rule.
147    ///
148    /// The description will be added as a comment above the rule in the output.
149    #[must_use]
150    pub fn description(mut self, description: impl Into<String>) -> Self {
151        self.description = Some(description.into());
152        self
153    }
154
155    /// Assign this rule to a section.
156    ///
157    /// Rules with the same section will be grouped together in the output.
158    #[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/// CODEOWNERS file configuration and generator.
166///
167/// Use [`Codeowners::builder()`] to create a new instance.
168///
169/// # Example
170///
171/// ```rust
172/// use cuenv_codeowners::{Codeowners, Platform, Rule};
173///
174/// let codeowners = Codeowners::builder()
175///     .platform(Platform::Github)
176///     .header("Custom header comment")
177///     .default_owners(["@org/maintainers"])
178///     .rule(Rule::new("*.rs", ["@rust-team"]))
179///     .build();
180///
181/// println!("{}", codeowners.generate());
182/// ```
183#[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    /// Target platform for the CODEOWNERS file.
189    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
190    pub platform: Option<Platform>,
191    /// Custom output path override.
192    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
193    pub path: Option<String>,
194    /// Custom header comment for the file.
195    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
196    pub header: Option<String>,
197    /// Default owners applied to all files (`*` rule).
198    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
199    pub default_owners: Option<Vec<String>>,
200    /// Ownership rules.
201    #[cfg_attr(feature = "serde", serde(default))]
202    pub rules: Vec<Rule>,
203}
204
205impl Codeowners {
206    /// Create a new builder for constructing a Codeowners configuration.
207    ///
208    /// # Example
209    ///
210    /// ```rust
211    /// use cuenv_codeowners::Codeowners;
212    ///
213    /// let codeowners = Codeowners::builder()
214    ///     .rule(cuenv_codeowners::Rule::new("*", ["@fallback-team"]))
215    ///     .build();
216    /// ```
217    #[must_use]
218    pub fn builder() -> CodeownersBuilder {
219        CodeownersBuilder::default()
220    }
221
222    /// Generate the CODEOWNERS file content.
223    ///
224    /// # Example
225    ///
226    /// ```rust
227    /// use cuenv_codeowners::{Codeowners, Rule};
228    ///
229    /// let codeowners = Codeowners::builder()
230    ///     .rule(Rule::new("*.rs", ["@rust-team"]))
231    ///     .build();
232    ///
233    /// let content = codeowners.generate();
234    /// assert!(content.contains("*.rs @rust-team"));
235    /// ```
236    #[must_use]
237    pub fn generate(&self) -> String {
238        let mut output = String::new();
239        let platform = self.platform.unwrap_or_default();
240
241        // Add header
242        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        // Add default owners if any
252        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        // Group rules by section for contiguous output
263        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            // Write section header if present
279            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            // Write all rules in this section
295            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    /// Get the output path for the CODEOWNERS file.
313    ///
314    /// Returns the custom path if set, otherwise the platform's default path.
315    #[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    /// Detect the platform from repository structure.
323    ///
324    /// Checks for platform-specific files/directories:
325    /// - `.github/` directory -> GitHub
326    /// - `.gitlab-ci.yml` file -> GitLab
327    /// - `bitbucket-pipelines.yml` file -> Bitbucket
328    /// - Falls back to GitHub if none detected
329    ///
330    /// # Example
331    ///
332    /// ```rust
333    /// use cuenv_codeowners::{Codeowners, Platform};
334    /// use std::path::Path;
335    ///
336    /// let platform = Codeowners::detect_platform(Path::new("."));
337    /// ```
338    #[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/// Builder for [`Codeowners`].
353///
354/// # Example
355///
356/// ```rust
357/// use cuenv_codeowners::{Codeowners, Platform, Rule};
358///
359/// let codeowners = Codeowners::builder()
360///     .platform(Platform::Github)
361///     .header("Code ownership rules")
362///     .default_owners(["@org/maintainers"])
363///     .rule(Rule::new("*.rs", ["@rust-team"]))
364///     .rules([
365///         Rule::new("/docs/**", ["@docs-team"]),
366///         Rule::new("/ci/**", ["@devops"]),
367///     ])
368///     .build();
369/// ```
370#[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    /// Set the target platform.
381    #[must_use]
382    pub fn platform(mut self, platform: Platform) -> Self {
383        self.platform = Some(platform);
384        self
385    }
386
387    /// Set a custom output path.
388    ///
389    /// Overrides the platform's default path.
390    #[must_use]
391    pub fn path(mut self, path: impl Into<String>) -> Self {
392        self.path = Some(path.into());
393        self
394    }
395
396    /// Set a custom header comment.
397    ///
398    /// The header will be added at the top of the file with `#` prefixes.
399    #[must_use]
400    pub fn header(mut self, header: impl Into<String>) -> Self {
401        self.header = Some(header.into());
402        self
403    }
404
405    /// Set default owners for all files.
406    ///
407    /// This creates a `* @owner1 @owner2` rule at the top of the file.
408    #[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    /// Add a single rule.
415    #[must_use]
416    pub fn rule(mut self, rule: Rule) -> Self {
417        self.rules.push(rule);
418        self
419    }
420
421    /// Add multiple rules.
422    #[must_use]
423    pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
424        self.rules.extend(rules);
425        self
426    }
427
428    /// Build the [`Codeowners`] configuration.
429    #[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        // Default (no config)
474        let codeowners = Codeowners::builder().build();
475        assert_eq!(codeowners.output_path(), ".github/CODEOWNERS");
476
477        // With platform
478        let codeowners = Codeowners::builder().platform(Platform::Gitlab).build();
479        assert_eq!(codeowners.output_path(), "CODEOWNERS");
480
481        // With custom path
482        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        // GitLab uses [Section] syntax
557        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        // Should NOT use comment-style sections
566        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        // Rules with same section should be grouped even if not contiguous in input
579        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        // Backend section should only appear once
588        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        // Both backend rules should be together
595        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}