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 serde::{Deserialize, Serialize};
11use std::collections::HashMap;
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, 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, 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, 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    /// Optional order for deterministic output (lower values appear first).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub order: Option<i32>,
113}
114
115/// Code ownership configuration for a project.
116///
117/// This type is designed for deserializing from CUE manifests. Use
118/// [`to_codeowners()`](Self::to_codeowners) to convert to the library type
119/// for generation.
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
121#[serde(rename_all = "camelCase")]
122pub struct Owners {
123    /// Output configuration for CODEOWNERS file generation.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub output: Option<OwnersOutput>,
126
127    /// Code ownership rules - maps rule names to rule definitions.
128    /// Using a map enables CUE unification/layering across configs.
129    #[serde(default)]
130    pub rules: HashMap<String, OwnerRule>,
131}
132
133impl Owners {
134    /// Convert to the library's [`Codeowners`] type for generation.
135    ///
136    /// This method converts the manifest configuration to the library type,
137    /// adding a default cuenv header if none is specified.
138    #[must_use]
139    pub fn to_codeowners(&self) -> Codeowners {
140        let mut builder = CodeownersBuilder::default();
141
142        // Set platform and path from output config
143        if let Some(ref output) = self.output {
144            if let Some(platform) = output.platform {
145                builder = builder.platform(platform.to_lib());
146            }
147            if let Some(ref path) = output.path {
148                builder = builder.path(path.clone());
149            }
150            if let Some(ref header) = output.header {
151                builder = builder.header(header.clone());
152            } else {
153                // Default cuenv header
154                builder = builder.header(
155                    "CODEOWNERS file - Generated by cuenv\n\
156                     Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
157                );
158            }
159        } else {
160            // Default cuenv header when no output config
161            builder = builder.header(
162                "CODEOWNERS file - Generated by cuenv\n\
163                 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
164            );
165        }
166
167        // Add rules - sort by order then by key for determinism
168        let mut rule_entries: Vec<_> = self.rules.iter().collect();
169        rule_entries.sort_by(|a, b| {
170            let order_a = a.1.order.unwrap_or(i32::MAX);
171            let order_b = b.1.order.unwrap_or(i32::MAX);
172            order_a.cmp(&order_b).then_with(|| a.0.cmp(b.0))
173        });
174
175        for (_key, rule) in rule_entries {
176            let mut lib_rule = cuenv_codeowners::Rule::new(&rule.pattern, rule.owners.clone());
177            if let Some(ref description) = rule.description {
178                lib_rule = lib_rule.description(description.clone());
179            }
180            if let Some(ref section) = rule.section {
181                lib_rule = lib_rule.section(section.clone());
182            }
183            builder = builder.rule(lib_rule);
184        }
185
186        builder.build()
187    }
188
189    /// Generate the CODEOWNERS file content.
190    ///
191    /// This is a convenience method that converts to [`Codeowners`] and
192    /// calls `generate()`.
193    #[must_use]
194    pub fn generate(&self) -> String {
195        self.to_codeowners().generate()
196    }
197
198    /// Get the output path for the CODEOWNERS file.
199    #[must_use]
200    pub fn output_path(&self) -> &str {
201        self.output
202            .as_ref()
203            .map(|o| o.output_path())
204            .unwrap_or_else(|| Platform::default().default_path())
205    }
206
207    /// Detect platform from repository structure.
208    ///
209    /// Delegates to the library's detection logic.
210    #[must_use]
211    pub fn detect_platform(repo_root: &Path) -> Platform {
212        Platform::from_lib(Codeowners::detect_platform(repo_root))
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_platform_default_paths() {
222        assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
223        assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
224        assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
225    }
226
227    #[test]
228    fn test_owners_output_path() {
229        // Default (no output config)
230        let owners = Owners::default();
231        assert_eq!(owners.output_path(), ".github/CODEOWNERS");
232
233        // With platform specified
234        let owners = Owners {
235            output: Some(OwnersOutput {
236                platform: Some(Platform::Gitlab),
237                path: None,
238                header: None,
239            }),
240            ..Default::default()
241        };
242        assert_eq!(owners.output_path(), "CODEOWNERS");
243
244        // With custom path
245        let owners = Owners {
246            output: Some(OwnersOutput {
247                platform: Some(Platform::Github),
248                path: Some("docs/CODEOWNERS".to_string()),
249                header: None,
250            }),
251            ..Default::default()
252        };
253        assert_eq!(owners.output_path(), "docs/CODEOWNERS");
254    }
255
256    #[test]
257    fn test_generate_simple() {
258        let mut rules = HashMap::new();
259        rules.insert(
260            "rust-files".to_string(),
261            OwnerRule {
262                pattern: "*.rs".to_string(),
263                owners: vec!["@rust-team".to_string()],
264                description: None,
265                section: None,
266                order: Some(1),
267            },
268        );
269        rules.insert(
270            "docs".to_string(),
271            OwnerRule {
272                pattern: "/docs/**".to_string(),
273                owners: vec!["@docs-team".to_string(), "@tech-writers".to_string()],
274                description: None,
275                section: None,
276                order: Some(2),
277            },
278        );
279
280        let owners = Owners {
281            rules,
282            ..Default::default()
283        };
284
285        let content = owners.generate();
286        assert!(content.contains("*.rs @rust-team"));
287        assert!(content.contains("/docs/** @docs-team @tech-writers"));
288    }
289
290    #[test]
291    fn test_generate_with_sections() {
292        let mut rules = HashMap::new();
293        rules.insert(
294            "rust-files".to_string(),
295            OwnerRule {
296                pattern: "*.rs".to_string(),
297                owners: vec!["@backend".to_string()],
298                description: Some("Rust source files".to_string()),
299                section: Some("Backend".to_string()),
300                order: Some(1),
301            },
302        );
303        rules.insert(
304            "typescript-files".to_string(),
305            OwnerRule {
306                pattern: "*.ts".to_string(),
307                owners: vec!["@frontend".to_string()],
308                description: None,
309                section: Some("Frontend".to_string()),
310                order: Some(2),
311            },
312        );
313
314        let owners = Owners {
315            rules,
316            ..Default::default()
317        };
318
319        let content = owners.generate();
320        assert!(content.contains("# Backend"));
321        assert!(content.contains("# Rust source files"));
322        assert!(content.contains("# Frontend"));
323    }
324
325    #[test]
326    fn test_generate_with_custom_header() {
327        let owners = Owners {
328            output: Some(OwnersOutput {
329                platform: None,
330                path: None,
331                header: Some("Custom Header\nLine 2".to_string()),
332            }),
333            rules: HashMap::new(),
334        };
335
336        let content = owners.generate();
337        assert!(content.contains("# Custom Header"));
338        assert!(content.contains("# Line 2"));
339    }
340
341    #[test]
342    fn test_generate_gitlab_sections() {
343        let mut rules = HashMap::new();
344        rules.insert(
345            "rust-files".to_string(),
346            OwnerRule {
347                pattern: "*.rs".to_string(),
348                owners: vec!["@backend".to_string()],
349                section: Some("Backend".to_string()),
350                description: None,
351                order: Some(1),
352            },
353        );
354        rules.insert(
355            "typescript-files".to_string(),
356            OwnerRule {
357                pattern: "*.ts".to_string(),
358                owners: vec!["@frontend".to_string()],
359                section: Some("Frontend".to_string()),
360                description: None,
361                order: Some(2),
362            },
363        );
364
365        let owners = Owners {
366            output: Some(OwnersOutput {
367                platform: Some(Platform::Gitlab),
368                path: None,
369                header: None,
370            }),
371            rules,
372        };
373
374        let content = owners.generate();
375        // GitLab uses [Section] syntax
376        assert!(
377            content.contains("[Backend]"),
378            "GitLab should use [Section] syntax, got: {content}"
379        );
380        assert!(
381            content.contains("[Frontend]"),
382            "GitLab should use [Section] syntax, got: {content}"
383        );
384        // Should NOT use comment-style sections
385        assert!(
386            !content.contains("# Backend"),
387            "GitLab should NOT use # Section"
388        );
389        assert!(
390            !content.contains("# Frontend"),
391            "GitLab should NOT use # Section"
392        );
393    }
394
395    #[test]
396    fn test_generate_groups_rules_by_section() {
397        // Test that rules with same section are grouped together
398        // Using order field to control ordering since HashMap doesn't preserve insertion order
399        let mut rules = HashMap::new();
400        rules.insert(
401            "rust-files".to_string(),
402            OwnerRule {
403                pattern: "*.rs".to_string(),
404                owners: vec!["@backend".to_string()],
405                section: Some("Backend".to_string()),
406                description: None,
407                order: Some(1),
408            },
409        );
410        rules.insert(
411            "typescript-files".to_string(),
412            OwnerRule {
413                pattern: "*.ts".to_string(),
414                owners: vec!["@frontend".to_string()],
415                section: Some("Frontend".to_string()),
416                description: None,
417                order: Some(2),
418            },
419        );
420        rules.insert(
421            "go-files".to_string(),
422            OwnerRule {
423                pattern: "*.go".to_string(),
424                owners: vec!["@backend".to_string()],
425                section: Some("Backend".to_string()),
426                description: None,
427                order: Some(3),
428            },
429        );
430
431        let owners = Owners {
432            rules,
433            ..Default::default()
434        };
435
436        let content = owners.generate();
437        // Backend section should only appear once
438        let backend_count = content.matches("# Backend").count();
439        assert_eq!(
440            backend_count, 1,
441            "Backend section should appear exactly once, found {backend_count} times"
442        );
443        // Both backend rules should be together
444        let backend_idx = content.find("# Backend").unwrap();
445        let rs_idx = content.find("*.rs").unwrap();
446        let go_idx = content.find("*.go").unwrap();
447        let frontend_idx = content.find("# Frontend").unwrap();
448        // Both .rs and .go should come after Backend header and before Frontend header
449        assert!(
450            rs_idx > backend_idx && rs_idx < frontend_idx,
451            "*.rs should be in Backend section"
452        );
453        assert!(
454            go_idx > backend_idx && go_idx < frontend_idx,
455            "*.go should be in Backend section"
456        );
457    }
458
459    #[test]
460    fn test_order_sorting() {
461        // Test that rules are sorted by order field
462        let mut rules = HashMap::new();
463        rules.insert(
464            "z-last".to_string(),
465            OwnerRule {
466                pattern: "*.last".to_string(),
467                owners: vec!["@team".to_string()],
468                description: None,
469                section: None,
470                order: Some(3),
471            },
472        );
473        rules.insert(
474            "a-first".to_string(),
475            OwnerRule {
476                pattern: "*.first".to_string(),
477                owners: vec!["@team".to_string()],
478                description: None,
479                section: None,
480                order: Some(1),
481            },
482        );
483        rules.insert(
484            "m-middle".to_string(),
485            OwnerRule {
486                pattern: "*.middle".to_string(),
487                owners: vec!["@team".to_string()],
488                description: None,
489                section: None,
490                order: Some(2),
491            },
492        );
493
494        let owners = Owners {
495            rules,
496            ..Default::default()
497        };
498
499        let content = owners.generate();
500        let first_idx = content.find("*.first").unwrap();
501        let middle_idx = content.find("*.middle").unwrap();
502        let last_idx = content.find("*.last").unwrap();
503
504        assert!(
505            first_idx < middle_idx && middle_idx < last_idx,
506            "Rules should be sorted by order: first={first_idx}, middle={middle_idx}, last={last_idx}"
507        );
508    }
509}