Skip to main content

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.
5//!
6//! Provider crates (cuenv-github, cuenv-gitlab, cuenv-bitbucket) handle the
7//! platform-specific logic (file paths, section styles) based on repository
8//! structure detection.
9//!
10//! Based on schema/owners.cue
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15/// Output configuration for CODEOWNERS file generation.
16///
17/// Note: The `platform` field is kept for CUE schema compatibility but is not
18/// used directly. The provider is detected at runtime based on repository
19/// structure (`.github/` directory, `.gitlab-ci.yml`, etc.).
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
21pub struct OwnersOutput {
22    /// Platform hint (e.g., "github", "gitlab", "bitbucket").
23    /// Provider is detected automatically; this field is for schema compatibility.
24    pub platform: Option<String>,
25
26    /// Custom path for CODEOWNERS file (overrides platform default).
27    pub path: Option<String>,
28
29    /// Header comment to include at the top of the generated file.
30    pub header: Option<String>,
31}
32
33/// A single code ownership rule.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct OwnerRule {
36    /// File pattern (glob syntax) - same as CODEOWNERS format.
37    pub pattern: String,
38
39    /// Owners for this pattern.
40    pub owners: Vec<String>,
41
42    /// Optional description for this rule (added as comment above the rule).
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub description: Option<String>,
45
46    /// Section name for grouping rules in the output file.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub section: Option<String>,
49
50    /// Optional order for deterministic output (lower values appear first).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub order: Option<i32>,
53}
54
55/// Code ownership configuration for a project.
56///
57/// This type is designed for deserializing from CUE manifests.
58/// Use `cuenv_codeowners` to convert to the library type for generation.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
60#[serde(rename_all = "camelCase")]
61pub struct Owners {
62    /// Output configuration for CODEOWNERS file generation.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub output: Option<OwnersOutput>,
65
66    /// Code ownership rules - maps rule names to rule definitions.
67    /// Using a map enables CUE unification/layering across configs.
68    #[serde(default)]
69    pub rules: HashMap<String, OwnerRule>,
70}
71
72impl Owners {
73    /// Get rules sorted by order then by key for determinism.
74    #[must_use]
75    pub fn sorted_rules(&self) -> Vec<(&String, &OwnerRule)> {
76        let mut rule_entries: Vec<_> = self.rules.iter().collect();
77        rule_entries.sort_by(|a, b| {
78            let order_a = a.1.order.unwrap_or(i32::MAX);
79            let order_b = b.1.order.unwrap_or(i32::MAX);
80            order_a.cmp(&order_b).then_with(|| a.0.cmp(b.0))
81        });
82        rule_entries
83    }
84
85    /// Get the header, if any.
86    #[must_use]
87    pub fn header(&self) -> Option<&str> {
88        self.output.as_ref().and_then(|o| o.header.as_deref())
89    }
90
91    /// Get the custom path, if specified.
92    #[must_use]
93    pub fn custom_path(&self) -> Option<&str> {
94        self.output.as_ref().and_then(|o| o.path.as_deref())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn test_sorted_rules() {
104        let mut rules = HashMap::new();
105        rules.insert(
106            "z-last".to_string(),
107            OwnerRule {
108                pattern: "*.last".to_string(),
109                owners: vec!["@team".to_string()],
110                description: None,
111                section: None,
112                order: Some(3),
113            },
114        );
115        rules.insert(
116            "a-first".to_string(),
117            OwnerRule {
118                pattern: "*.first".to_string(),
119                owners: vec!["@team".to_string()],
120                description: None,
121                section: None,
122                order: Some(1),
123            },
124        );
125        rules.insert(
126            "m-middle".to_string(),
127            OwnerRule {
128                pattern: "*.middle".to_string(),
129                owners: vec!["@team".to_string()],
130                description: None,
131                section: None,
132                order: Some(2),
133            },
134        );
135
136        let owners = Owners {
137            rules,
138            ..Default::default()
139        };
140
141        let sorted = owners.sorted_rules();
142        assert_eq!(sorted.len(), 3);
143        assert_eq!(sorted[0].0, "a-first");
144        assert_eq!(sorted[1].0, "m-middle");
145        assert_eq!(sorted[2].0, "z-last");
146    }
147
148    #[test]
149    fn test_owners_header() {
150        // No header
151        let owners = Owners::default();
152        assert!(owners.header().is_none());
153
154        // With header
155        let owners = Owners {
156            output: Some(OwnersOutput {
157                platform: None,
158                path: None,
159                header: Some("Custom Header".to_string()),
160            }),
161            ..Default::default()
162        };
163        assert_eq!(owners.header(), Some("Custom Header"));
164    }
165
166    #[test]
167    fn test_owners_custom_path() {
168        // No custom path
169        let owners = Owners::default();
170        assert!(owners.custom_path().is_none());
171
172        // With custom path
173        let owners = Owners {
174            output: Some(OwnersOutput {
175                platform: None,
176                path: Some("docs/CODEOWNERS".to_string()),
177                header: None,
178            }),
179            ..Default::default()
180        };
181        assert_eq!(owners.custom_path(), Some("docs/CODEOWNERS"));
182    }
183
184    #[test]
185    fn test_owners_output_platform_string() {
186        // Platform can be set as a string hint
187        let owners = Owners {
188            output: Some(OwnersOutput {
189                platform: Some("gitlab".to_string()),
190                path: None,
191                header: None,
192            }),
193            ..Default::default()
194        };
195        assert_eq!(
196            owners.output.as_ref().unwrap().platform,
197            Some("gitlab".to_string())
198        );
199    }
200}