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