Skip to main content

cuenv_codeowners/
lib.rs

1//! Generate CODEOWNERS files with configurable formatting.
2//!
3//! This crate provides a builder-based API for generating CODEOWNERS files
4//! that define code ownership rules for your repository. It is provider-agnostic;
5//! platform-specific logic (paths, section styles) belongs in provider crates
6//! like `cuenv-github` or `cuenv-gitlab`.
7//!
8//! # Example
9//!
10//! ```rust
11//! use cuenv_codeowners::{CodeOwners, SectionStyle, Rule};
12//!
13//! let codeowners = CodeOwners::builder()
14//!     .section_style(SectionStyle::Comment)  // GitHub/Bitbucket style
15//!     .rule(Rule::new("*", ["@org/core-team"]))
16//!     .rule(Rule::new("*.rs", ["@rust-team"]))
17//!     .rule(Rule::new("/docs/**", ["@docs-team"]).section("Documentation"))
18//!     .build();
19//!
20//! let content = codeowners.generate();
21//! ```
22//!
23//! # Section Styles
24//!
25//! - `Comment`: `# Section Name` (used by GitHub, Bitbucket)
26//! - `Bracket`: `[Section Name]` (used by GitLab)
27//! - `None`: No section headers
28//!
29//! # Provider Support
30//!
31//! The [`provider`] module provides a trait-based abstraction for syncing
32//! CODEOWNERS files. Provider implementations live in platform crates.
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;
45
46#[cfg(feature = "schemars")]
47use schemars::JsonSchema;
48#[cfg(feature = "serde")]
49use serde::{Deserialize, Serialize};
50
51/// Section formatting style for CODEOWNERS files.
52///
53/// Different platforms use different syntax for section headers:
54/// - GitHub/Bitbucket: `# Section Name` (comment style)
55/// - GitLab: `[Section Name]` (bracket style)
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
58#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
59#[cfg_attr(feature = "schemars", derive(JsonSchema))]
60pub enum SectionStyle {
61    /// `# Section Name` - comment-based sections (GitHub, Bitbucket)
62    #[default]
63    Comment,
64    /// `[Section Name]` - bracket-based sections (GitLab)
65    Bracket,
66    /// No section headers in output
67    None,
68}
69
70impl fmt::Display for SectionStyle {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            SectionStyle::Comment => write!(f, "comment"),
74            SectionStyle::Bracket => write!(f, "bracket"),
75            SectionStyle::None => write!(f, "none"),
76        }
77    }
78}
79
80/// A single code ownership rule.
81///
82/// Each rule maps a file pattern to one or more owners.
83///
84/// # Example
85///
86/// ```rust
87/// use cuenv_codeowners::Rule;
88///
89/// let rule = Rule::new("*.rs", ["@rust-team", "@backend"])
90///     .description("Rust source files")
91///     .section("Backend");
92/// ```
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
95#[cfg_attr(feature = "schemars", derive(JsonSchema))]
96pub struct Rule {
97    /// File pattern (glob syntax) matching files this rule applies to.
98    pub pattern: String,
99    /// List of owners for files matching this pattern.
100    pub owners: Vec<String>,
101    /// Optional description added as a comment above the rule.
102    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
103    pub description: Option<String>,
104    /// Optional section name for grouping rules in the output.
105    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
106    pub section: Option<String>,
107}
108
109impl Rule {
110    /// Create a new rule with a pattern and owners.
111    ///
112    /// # Example
113    ///
114    /// ```rust
115    /// use cuenv_codeowners::Rule;
116    ///
117    /// let rule = Rule::new("*.rs", ["@rust-team"]);
118    /// let rule = Rule::new("/docs/**", vec!["@docs-team", "@tech-writers"]);
119    /// ```
120    pub fn new(
121        pattern: impl Into<String>,
122        owners: impl IntoIterator<Item = impl Into<String>>,
123    ) -> Self {
124        Self {
125            pattern: pattern.into(),
126            owners: owners.into_iter().map(Into::into).collect(),
127            description: None,
128            section: None,
129        }
130    }
131
132    /// Add a description to this rule.
133    ///
134    /// The description will be added as a comment above the rule in the output.
135    #[must_use]
136    pub fn description(mut self, description: impl Into<String>) -> Self {
137        self.description = Some(description.into());
138        self
139    }
140
141    /// Assign this rule to a section.
142    ///
143    /// Rules with the same section will be grouped together in the output.
144    #[must_use]
145    pub fn section(mut self, section: impl Into<String>) -> Self {
146        self.section = Some(section.into());
147        self
148    }
149}
150
151/// CODEOWNERS file configuration and generator.
152///
153/// Use [`CodeOwners::builder()`] to create a new instance.
154///
155/// # Example
156///
157/// ```rust
158/// use cuenv_codeowners::{CodeOwners, SectionStyle, Rule};
159///
160/// let codeowners = CodeOwners::builder()
161///     .section_style(SectionStyle::Comment)
162///     .header("Custom header comment")
163///     .rule(Rule::new("*", ["@org/maintainers"]))
164///     .rule(Rule::new("*.rs", ["@rust-team"]))
165///     .build();
166///
167/// println!("{}", codeowners.generate());
168/// ```
169#[derive(Debug, Clone, PartialEq, Eq, Default)]
170#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
171#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
172#[cfg_attr(feature = "schemars", derive(JsonSchema))]
173pub struct CodeOwners {
174    /// Section formatting style.
175    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
176    pub section_style: Option<SectionStyle>,
177    /// Custom header comment for the file.
178    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
179    pub header: Option<String>,
180    /// Ownership rules.
181    #[cfg_attr(feature = "serde", serde(default))]
182    pub rules: Vec<Rule>,
183}
184
185impl CodeOwners {
186    /// Create a new builder for constructing a CodeOwners configuration.
187    ///
188    /// # Example
189    ///
190    /// ```rust
191    /// use cuenv_codeowners::CodeOwners;
192    ///
193    /// let codeowners = CodeOwners::builder()
194    ///     .rule(cuenv_codeowners::Rule::new("*", ["@fallback-team"]))
195    ///     .build();
196    /// ```
197    #[must_use]
198    pub fn builder() -> CodeOwnersBuilder {
199        CodeOwnersBuilder::default()
200    }
201
202    /// Generate the CODEOWNERS file content.
203    ///
204    /// # Example
205    ///
206    /// ```rust
207    /// use cuenv_codeowners::{CodeOwners, Rule};
208    ///
209    /// let codeowners = CodeOwners::builder()
210    ///     .rule(Rule::new("*.rs", ["@rust-team"]))
211    ///     .build();
212    ///
213    /// let content = codeowners.generate();
214    /// assert!(content.contains("*.rs @rust-team"));
215    /// ```
216    #[must_use]
217    pub fn generate(&self) -> String {
218        let mut output = String::new();
219        let section_style = self.section_style.unwrap_or_default();
220
221        // Add header
222        if let Some(ref header) = self.header {
223            for line in header.lines() {
224                output.push_str("# ");
225                output.push_str(line);
226                output.push('\n');
227            }
228            output.push('\n');
229        }
230
231        // Group rules by section for contiguous output
232        let mut rules_by_section: BTreeMap<Option<&str>, Vec<&Rule>> = BTreeMap::new();
233        for rule in &self.rules {
234            rules_by_section
235                .entry(rule.section.as_deref())
236                .or_default()
237                .push(rule);
238        }
239
240        let mut first_section = true;
241        for (section, rules) in rules_by_section {
242            if !first_section {
243                output.push('\n');
244            }
245            first_section = false;
246
247            // Write section header if present
248            if let Some(section_name) = section {
249                match section_style {
250                    SectionStyle::Bracket => {
251                        output.push('[');
252                        output.push_str(section_name);
253                        output.push_str("]\n");
254                    }
255                    SectionStyle::Comment => {
256                        output.push_str("# ");
257                        output.push_str(section_name);
258                        output.push('\n');
259                    }
260                    SectionStyle::None => {
261                        // No section header
262                    }
263                }
264            }
265
266            // Write all rules in this section
267            for rule in rules {
268                if let Some(ref description) = rule.description {
269                    output.push_str("# ");
270                    output.push_str(description);
271                    output.push('\n');
272                }
273
274                output.push_str(&rule.pattern);
275                output.push(' ');
276                output.push_str(&rule.owners.join(" "));
277                output.push('\n');
278            }
279        }
280
281        output
282    }
283}
284
285/// Builder for [`CodeOwners`].
286///
287/// # Example
288///
289/// ```rust
290/// use cuenv_codeowners::{CodeOwners, SectionStyle, Rule};
291///
292/// let codeowners = CodeOwners::builder()
293///     .section_style(SectionStyle::Comment)
294///     .header("Code ownership rules")
295///     .rule(Rule::new("*", ["@org/maintainers"]))
296///     .rule(Rule::new("*.rs", ["@rust-team"]))
297///     .rules([
298///         Rule::new("/docs/**", ["@docs-team"]),
299///         Rule::new("/ci/**", ["@devops"]),
300///     ])
301///     .build();
302/// ```
303#[derive(Debug, Clone, Default)]
304pub struct CodeOwnersBuilder {
305    section_style: Option<SectionStyle>,
306    header: Option<String>,
307    rules: Vec<Rule>,
308}
309
310impl CodeOwnersBuilder {
311    /// Set the section formatting style.
312    #[must_use]
313    pub fn section_style(mut self, style: SectionStyle) -> Self {
314        self.section_style = Some(style);
315        self
316    }
317
318    /// Set a custom header comment.
319    ///
320    /// The header will be added at the top of the file with `#` prefixes.
321    #[must_use]
322    pub fn header(mut self, header: impl Into<String>) -> Self {
323        self.header = Some(header.into());
324        self
325    }
326
327    /// Add a single rule.
328    #[must_use]
329    pub fn rule(mut self, rule: Rule) -> Self {
330        self.rules.push(rule);
331        self
332    }
333
334    /// Add multiple rules.
335    #[must_use]
336    pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
337        self.rules.extend(rules);
338        self
339    }
340
341    /// Build the [`CodeOwners`] configuration.
342    #[must_use]
343    pub fn build(self) -> CodeOwners {
344        CodeOwners {
345            section_style: self.section_style,
346            header: self.header,
347            rules: self.rules,
348        }
349    }
350}
351
352/// Default cuenv header for generated CODEOWNERS files.
353pub const DEFAULT_CUENV_HEADER: &str = "CODEOWNERS file - Generated by cuenv\nDo not edit manually. Configure in env.cue and run `cuenv owners sync`";
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_section_style_display() {
361        assert_eq!(SectionStyle::Comment.to_string(), "comment");
362        assert_eq!(SectionStyle::Bracket.to_string(), "bracket");
363        assert_eq!(SectionStyle::None.to_string(), "none");
364    }
365
366    #[test]
367    fn test_rule_builder() {
368        let rule = Rule::new("*.rs", ["@rust-team"])
369            .description("Rust files")
370            .section("Backend");
371
372        assert_eq!(rule.pattern, "*.rs");
373        assert_eq!(rule.owners, vec!["@rust-team"]);
374        assert_eq!(rule.description, Some("Rust files".to_string()));
375        assert_eq!(rule.section, Some("Backend".to_string()));
376    }
377
378    #[test]
379    fn test_generate_simple() {
380        let codeowners = CodeOwners::builder()
381            .rule(Rule::new("*.rs", ["@rust-team"]))
382            .rule(Rule::new("/docs/**", ["@docs-team", "@tech-writers"]))
383            .build();
384
385        let content = codeowners.generate();
386        assert!(content.contains("*.rs @rust-team"));
387        assert!(content.contains("/docs/** @docs-team @tech-writers"));
388    }
389
390    #[test]
391    fn test_generate_with_sections_comment_style() {
392        let codeowners = CodeOwners::builder()
393            .section_style(SectionStyle::Comment)
394            .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
395            .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
396            .build();
397
398        let content = codeowners.generate();
399        assert!(content.contains("# Backend"));
400        assert!(content.contains("# Frontend"));
401    }
402
403    #[test]
404    fn test_generate_with_custom_header() {
405        let codeowners = CodeOwners::builder()
406            .header("Custom Header\nLine 2")
407            .build();
408
409        let content = codeowners.generate();
410        assert!(content.contains("# Custom Header"));
411        assert!(content.contains("# Line 2"));
412    }
413
414    #[test]
415    fn test_generate_with_descriptions() {
416        let codeowners = CodeOwners::builder()
417            .rule(Rule::new("*.rs", ["@rust-team"]).description("Rust source files"))
418            .build();
419
420        let content = codeowners.generate();
421        assert!(content.contains("# Rust source files"));
422        assert!(content.contains("*.rs @rust-team"));
423    }
424
425    #[test]
426    fn test_generate_bracket_sections() {
427        let codeowners = CodeOwners::builder()
428            .section_style(SectionStyle::Bracket)
429            .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
430            .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
431            .build();
432
433        let content = codeowners.generate();
434        // Bracket style uses [Section] syntax
435        assert!(
436            content.contains("[Backend]"),
437            "Bracket style should use [Section] syntax, got: {content}"
438        );
439        assert!(
440            content.contains("[Frontend]"),
441            "Bracket style should use [Section] syntax, got: {content}"
442        );
443        // Should NOT use comment-style sections
444        assert!(
445            !content.contains("# Backend"),
446            "Bracket style should NOT use # Section"
447        );
448        assert!(
449            !content.contains("# Frontend"),
450            "Bracket style should NOT use # Section"
451        );
452    }
453
454    #[test]
455    fn test_generate_no_section_headers() {
456        let codeowners = CodeOwners::builder()
457            .section_style(SectionStyle::None)
458            .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
459            .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
460            .build();
461
462        let content = codeowners.generate();
463        // No section headers should appear
464        assert!(
465            !content.contains("Backend"),
466            "SectionStyle::None should not include section headers"
467        );
468        assert!(
469            !content.contains("Frontend"),
470            "SectionStyle::None should not include section headers"
471        );
472        // Rules should still be present
473        assert!(content.contains("*.rs @backend"));
474        assert!(content.contains("*.ts @frontend"));
475    }
476
477    #[test]
478    fn test_generate_groups_rules_by_section() {
479        // Rules with same section should be grouped even if not contiguous in input
480        let codeowners = CodeOwners::builder()
481            .section_style(SectionStyle::Comment)
482            .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
483            .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
484            .rule(Rule::new("*.go", ["@backend"]).section("Backend"))
485            .build();
486
487        let content = codeowners.generate();
488
489        // Backend section should only appear once
490        let backend_count = content.matches("# Backend").count();
491        assert_eq!(
492            backend_count, 1,
493            "Backend section should appear exactly once, found {backend_count} times"
494        );
495
496        // Both backend rules should be together
497        let backend_idx = content.find("# Backend").unwrap();
498        let rs_idx = content.find("*.rs").unwrap();
499        let go_idx = content.find("*.go").unwrap();
500        let frontend_idx = content.find("# Frontend").unwrap();
501
502        assert!(
503            rs_idx > backend_idx && rs_idx < frontend_idx,
504            "*.rs should be in Backend section"
505        );
506        assert!(
507            go_idx > backend_idx && go_idx < frontend_idx,
508            "*.go should be in Backend section"
509        );
510    }
511
512    #[test]
513    fn test_builder_chaining() {
514        let codeowners = CodeOwners::builder()
515            .section_style(SectionStyle::Comment)
516            .header("Code ownership")
517            .rule(Rule::new("*.rs", ["@rust"]))
518            .rules([
519                Rule::new("*.ts", ["@typescript"]),
520                Rule::new("*.py", ["@python"]),
521            ])
522            .build();
523
524        assert_eq!(codeowners.section_style, Some(SectionStyle::Comment));
525        assert_eq!(codeowners.header, Some("Code ownership".to_string()));
526        assert_eq!(codeowners.rules.len(), 3);
527    }
528}