cuenv_core/
owners.rs

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