1use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::path::Path;
9
10#[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 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
43pub struct OwnersOutput {
44 pub platform: Option<Platform>,
46
47 pub path: Option<String>,
49
50 pub header: Option<String>,
52}
53
54impl OwnersOutput {
55 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
67pub struct OwnerRule {
68 pub pattern: String,
70
71 pub owners: Vec<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub description: Option<String>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub section: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
85#[serde(rename_all = "camelCase")]
86pub struct Owners {
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub output: Option<OwnersOutput>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub default_owners: Option<Vec<String>>,
94
95 #[serde(default)]
97 pub rules: Vec<OwnerRule>,
98}
99
100impl Owners {
101 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 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 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 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 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 if let Some(section_name) = section {
157 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 for rule in rules {
175 if let Some(ref description) = rule.description {
177 output.push_str("# ");
178 output.push_str(description);
179 output.push('\n');
180 }
181
182 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 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 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 }
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 let owners = Owners::default();
230 assert_eq!(owners.output_path(), ".github/CODEOWNERS");
231
232 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 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 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 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 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 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 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 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}