cuenv_core/
owners.rs

1//! Code ownership configuration types for cuenv manifests.
2//!
3//! This module provides serde-compatible types for deserializing CODEOWNERS
4//! configuration from CUE manifests. It wraps the `cuenv_codeowners` library
5//! which provides the actual generation logic.
6//!
7//! Based on schema/owners.cue
8
9use cuenv_codeowners::{Codeowners, CodeownersBuilder};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use std::path::Path;
14
15// Re-export the library's Platform type for convenience, but also define our own
16// for serde/schemars compatibility in manifests.
17pub use cuenv_codeowners::Platform as LibPlatform;
18
19/// Platform for CODEOWNERS file generation.
20///
21/// This is the manifest-compatible version with serde/schemars derives.
22#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
23#[serde(rename_all = "lowercase")]
24pub enum Platform {
25    /// GitHub - uses `.github/CODEOWNERS`
26    #[default]
27    Github,
28    /// GitLab - uses `CODEOWNERS` with `[Section]` syntax
29    Gitlab,
30    /// Bitbucket - uses `CODEOWNERS`
31    Bitbucket,
32}
33
34impl Platform {
35    /// Get the default path for CODEOWNERS file on this platform.
36    #[must_use]
37    pub fn default_path(&self) -> &'static str {
38        self.to_lib().default_path()
39    }
40
41    /// Convert to the library's Platform type.
42    #[must_use]
43    pub fn to_lib(self) -> LibPlatform {
44        match self {
45            Self::Github => LibPlatform::Github,
46            Self::Gitlab => LibPlatform::Gitlab,
47            Self::Bitbucket => LibPlatform::Bitbucket,
48        }
49    }
50
51    /// Convert from the library's Platform type.
52    #[must_use]
53    pub fn from_lib(platform: LibPlatform) -> Self {
54        match platform {
55            LibPlatform::Github => Self::Github,
56            LibPlatform::Gitlab => Self::Gitlab,
57            LibPlatform::Bitbucket => Self::Bitbucket,
58        }
59    }
60}
61
62impl fmt::Display for Platform {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(f, "{}", self.to_lib())
65    }
66}
67
68/// Output configuration for CODEOWNERS file generation.
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
70pub struct OwnersOutput {
71    /// Platform to generate CODEOWNERS for.
72    pub platform: Option<Platform>,
73
74    /// Custom path for CODEOWNERS file (overrides platform default).
75    pub path: Option<String>,
76
77    /// Header comment to include at the top of the generated file.
78    pub header: Option<String>,
79}
80
81impl OwnersOutput {
82    /// Get the output path for the CODEOWNERS file.
83    #[must_use]
84    pub fn output_path(&self) -> &str {
85        if let Some(ref path) = self.path {
86            path
87        } else {
88            self.platform.unwrap_or_default().default_path()
89        }
90    }
91}
92
93/// A single code ownership rule.
94#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
95pub struct OwnerRule {
96    /// File pattern (glob syntax) - same as CODEOWNERS format.
97    pub pattern: String,
98
99    /// Owners for this pattern.
100    pub owners: Vec<String>,
101
102    /// Optional description for this rule (added as comment above the rule).
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub description: Option<String>,
105
106    /// Section name for grouping rules in the output file.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub section: Option<String>,
109}
110
111/// Code ownership configuration for a project.
112///
113/// This type is designed for deserializing from CUE manifests. Use
114/// [`to_codeowners()`](Self::to_codeowners) to convert to the library type
115/// for generation.
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
117#[serde(rename_all = "camelCase")]
118pub struct Owners {
119    /// Output configuration for CODEOWNERS file generation.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub output: Option<OwnersOutput>,
122
123    /// Global default owners applied to all patterns without explicit owners.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub default_owners: Option<Vec<String>>,
126
127    /// Code ownership rules - maps patterns to owners.
128    #[serde(default)]
129    pub rules: Vec<OwnerRule>,
130}
131
132impl Owners {
133    /// Convert to the library's [`Codeowners`] type for generation.
134    ///
135    /// This method converts the manifest configuration to the library type,
136    /// adding a default cuenv header if none is specified.
137    #[must_use]
138    pub fn to_codeowners(&self) -> Codeowners {
139        let mut builder = CodeownersBuilder::default();
140
141        // Set platform and path from output config
142        if let Some(ref output) = self.output {
143            if let Some(platform) = output.platform {
144                builder = builder.platform(platform.to_lib());
145            }
146            if let Some(ref path) = output.path {
147                builder = builder.path(path.clone());
148            }
149            if let Some(ref header) = output.header {
150                builder = builder.header(header.clone());
151            } else {
152                // Default cuenv header
153                builder = builder.header(
154                    "CODEOWNERS file - Generated by cuenv\n\
155                     Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
156                );
157            }
158        } else {
159            // Default cuenv header when no output config
160            builder = builder.header(
161                "CODEOWNERS file - Generated by cuenv\n\
162                 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
163            );
164        }
165
166        // Set default owners
167        if let Some(ref default_owners) = self.default_owners {
168            builder = builder.default_owners(default_owners.clone());
169        }
170
171        // Add rules
172        for rule in &self.rules {
173            let mut lib_rule = cuenv_codeowners::Rule::new(&rule.pattern, rule.owners.clone());
174            if let Some(ref description) = rule.description {
175                lib_rule = lib_rule.description(description.clone());
176            }
177            if let Some(ref section) = rule.section {
178                lib_rule = lib_rule.section(section.clone());
179            }
180            builder = builder.rule(lib_rule);
181        }
182
183        builder.build()
184    }
185
186    /// Generate the CODEOWNERS file content.
187    ///
188    /// This is a convenience method that converts to [`Codeowners`] and
189    /// calls `generate()`.
190    #[must_use]
191    pub fn generate(&self) -> String {
192        self.to_codeowners().generate()
193    }
194
195    /// Get the output path for the CODEOWNERS file.
196    #[must_use]
197    pub fn output_path(&self) -> &str {
198        self.output
199            .as_ref()
200            .map(|o| o.output_path())
201            .unwrap_or_else(|| Platform::default().default_path())
202    }
203
204    /// Detect platform from repository structure.
205    ///
206    /// Delegates to the library's detection logic.
207    #[must_use]
208    pub fn detect_platform(repo_root: &Path) -> Platform {
209        Platform::from_lib(Codeowners::detect_platform(repo_root))
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_platform_default_paths() {
219        assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
220        assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
221        assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
222    }
223
224    #[test]
225    fn test_owners_output_path() {
226        // Default (no output config)
227        let owners = Owners::default();
228        assert_eq!(owners.output_path(), ".github/CODEOWNERS");
229
230        // With platform specified
231        let owners = Owners {
232            output: Some(OwnersOutput {
233                platform: Some(Platform::Gitlab),
234                path: None,
235                header: None,
236            }),
237            ..Default::default()
238        };
239        assert_eq!(owners.output_path(), "CODEOWNERS");
240
241        // With custom path
242        let owners = Owners {
243            output: Some(OwnersOutput {
244                platform: Some(Platform::Github),
245                path: Some("docs/CODEOWNERS".to_string()),
246                header: None,
247            }),
248            ..Default::default()
249        };
250        assert_eq!(owners.output_path(), "docs/CODEOWNERS");
251    }
252
253    #[test]
254    fn test_generate_simple() {
255        let owners = Owners {
256            rules: vec![
257                OwnerRule {
258                    pattern: "*.rs".to_string(),
259                    owners: vec!["@rust-team".to_string()],
260                    description: None,
261                    section: None,
262                },
263                OwnerRule {
264                    pattern: "/docs/**".to_string(),
265                    owners: vec!["@docs-team".to_string(), "@tech-writers".to_string()],
266                    description: None,
267                    section: None,
268                },
269            ],
270            ..Default::default()
271        };
272
273        let content = owners.generate();
274        assert!(content.contains("*.rs @rust-team"));
275        assert!(content.contains("/docs/** @docs-team @tech-writers"));
276    }
277
278    #[test]
279    fn test_generate_with_sections() {
280        let owners = Owners {
281            rules: vec![
282                OwnerRule {
283                    pattern: "*.rs".to_string(),
284                    owners: vec!["@backend".to_string()],
285                    description: Some("Rust source files".to_string()),
286                    section: Some("Backend".to_string()),
287                },
288                OwnerRule {
289                    pattern: "*.ts".to_string(),
290                    owners: vec!["@frontend".to_string()],
291                    description: None,
292                    section: Some("Frontend".to_string()),
293                },
294            ],
295            ..Default::default()
296        };
297
298        let content = owners.generate();
299        assert!(content.contains("# Backend"));
300        assert!(content.contains("# Rust source files"));
301        assert!(content.contains("# Frontend"));
302    }
303
304    #[test]
305    fn test_generate_with_default_owners() {
306        let owners = Owners {
307            default_owners: Some(vec!["@core-team".to_string()]),
308            rules: vec![OwnerRule {
309                pattern: "/security/**".to_string(),
310                owners: vec!["@security-team".to_string()],
311                description: None,
312                section: None,
313            }],
314            ..Default::default()
315        };
316
317        let content = owners.generate();
318        assert!(content.contains("* @core-team"));
319        assert!(content.contains("/security/** @security-team"));
320    }
321
322    #[test]
323    fn test_generate_with_custom_header() {
324        let owners = Owners {
325            output: Some(OwnersOutput {
326                platform: None,
327                path: None,
328                header: Some("Custom Header\nLine 2".to_string()),
329            }),
330            rules: vec![],
331            ..Default::default()
332        };
333
334        let content = owners.generate();
335        assert!(content.contains("# Custom Header"));
336        assert!(content.contains("# Line 2"));
337    }
338
339    #[test]
340    fn test_generate_gitlab_sections() {
341        let owners = Owners {
342            output: Some(OwnersOutput {
343                platform: Some(Platform::Gitlab),
344                path: None,
345                header: None,
346            }),
347            rules: vec![
348                OwnerRule {
349                    pattern: "*.rs".to_string(),
350                    owners: vec!["@backend".to_string()],
351                    section: Some("Backend".to_string()),
352                    description: None,
353                },
354                OwnerRule {
355                    pattern: "*.ts".to_string(),
356                    owners: vec!["@frontend".to_string()],
357                    section: Some("Frontend".to_string()),
358                    description: None,
359                },
360            ],
361            ..Default::default()
362        };
363
364        let content = owners.generate();
365        // GitLab uses [Section] syntax
366        assert!(
367            content.contains("[Backend]"),
368            "GitLab should use [Section] syntax, got: {content}"
369        );
370        assert!(
371            content.contains("[Frontend]"),
372            "GitLab should use [Section] syntax, got: {content}"
373        );
374        // Should NOT use comment-style sections
375        assert!(
376            !content.contains("# Backend"),
377            "GitLab should NOT use # Section"
378        );
379        assert!(
380            !content.contains("# Frontend"),
381            "GitLab should NOT use # Section"
382        );
383    }
384
385    #[test]
386    fn test_generate_groups_rules_by_section() {
387        // Test that rules with same section are grouped together even if not contiguous in input
388        let owners = Owners {
389            rules: vec![
390                OwnerRule {
391                    pattern: "*.rs".to_string(),
392                    owners: vec!["@backend".to_string()],
393                    section: Some("Backend".to_string()),
394                    description: None,
395                },
396                OwnerRule {
397                    pattern: "*.ts".to_string(),
398                    owners: vec!["@frontend".to_string()],
399                    section: Some("Frontend".to_string()),
400                    description: None,
401                },
402                OwnerRule {
403                    pattern: "*.go".to_string(),
404                    owners: vec!["@backend".to_string()],
405                    section: Some("Backend".to_string()),
406                    description: None,
407                },
408            ],
409            ..Default::default()
410        };
411
412        let content = owners.generate();
413        // Backend section should only appear once
414        let backend_count = content.matches("# Backend").count();
415        assert_eq!(
416            backend_count, 1,
417            "Backend section should appear exactly once, found {backend_count} times"
418        );
419        // Both backend rules should be together
420        let backend_idx = content.find("# Backend").unwrap();
421        let rs_idx = content.find("*.rs").unwrap();
422        let go_idx = content.find("*.go").unwrap();
423        let frontend_idx = content.find("# Frontend").unwrap();
424        // Both .rs and .go should come after Backend header and before Frontend header
425        assert!(
426            rs_idx > backend_idx && rs_idx < frontend_idx,
427            "*.rs should be in Backend section"
428        );
429        assert!(
430            go_idx > backend_idx && go_idx < frontend_idx,
431            "*.go should be in Backend section"
432        );
433    }
434}