1use cuenv_codeowners::{Codeowners, CodeownersBuilder};
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use std::path::Path;
14
15pub use cuenv_codeowners::Platform as LibPlatform;
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
23#[serde(rename_all = "lowercase")]
24pub enum Platform {
25 #[default]
27 Github,
28 Gitlab,
30 Bitbucket,
32}
33
34impl Platform {
35 #[must_use]
37 pub fn default_path(&self) -> &'static str {
38 self.to_lib().default_path()
39 }
40
41 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
70pub struct OwnersOutput {
71 pub platform: Option<Platform>,
73
74 pub path: Option<String>,
76
77 pub header: Option<String>,
79}
80
81impl OwnersOutput {
82 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
95pub struct OwnerRule {
96 pub pattern: String,
98
99 pub owners: Vec<String>,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub description: Option<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub section: Option<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
117#[serde(rename_all = "camelCase")]
118pub struct Owners {
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub output: Option<OwnersOutput>,
122
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub default_owners: Option<Vec<String>>,
126
127 #[serde(default)]
129 pub rules: Vec<OwnerRule>,
130}
131
132impl Owners {
133 #[must_use]
138 pub fn to_codeowners(&self) -> Codeowners {
139 let mut builder = CodeownersBuilder::default();
140
141 if let Some(ref output) = self.output {
143 if let Some(platform) = output.platform {
144 builder = builder.platform(platform.to_lib());
145 }
146 if let Some(ref path) = output.path {
147 builder = builder.path(path.clone());
148 }
149 if let Some(ref header) = output.header {
150 builder = builder.header(header.clone());
151 } else {
152 builder = builder.header(
154 "CODEOWNERS file - Generated by cuenv\n\
155 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
156 );
157 }
158 } else {
159 builder = builder.header(
161 "CODEOWNERS file - Generated by cuenv\n\
162 Do not edit manually. Configure in env.cue and run `cuenv owners sync`",
163 );
164 }
165
166 if let Some(ref default_owners) = self.default_owners {
168 builder = builder.default_owners(default_owners.clone());
169 }
170
171 for rule in &self.rules {
173 let mut lib_rule = cuenv_codeowners::Rule::new(&rule.pattern, rule.owners.clone());
174 if let Some(ref description) = rule.description {
175 lib_rule = lib_rule.description(description.clone());
176 }
177 if let Some(ref section) = rule.section {
178 lib_rule = lib_rule.section(section.clone());
179 }
180 builder = builder.rule(lib_rule);
181 }
182
183 builder.build()
184 }
185
186 #[must_use]
191 pub fn generate(&self) -> String {
192 self.to_codeowners().generate()
193 }
194
195 #[must_use]
197 pub fn output_path(&self) -> &str {
198 self.output
199 .as_ref()
200 .map(|o| o.output_path())
201 .unwrap_or_else(|| Platform::default().default_path())
202 }
203
204 #[must_use]
208 pub fn detect_platform(repo_root: &Path) -> Platform {
209 Platform::from_lib(Codeowners::detect_platform(repo_root))
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn test_platform_default_paths() {
219 assert_eq!(Platform::Github.default_path(), ".github/CODEOWNERS");
220 assert_eq!(Platform::Gitlab.default_path(), "CODEOWNERS");
221 assert_eq!(Platform::Bitbucket.default_path(), "CODEOWNERS");
222 }
223
224 #[test]
225 fn test_owners_output_path() {
226 let owners = Owners::default();
228 assert_eq!(owners.output_path(), ".github/CODEOWNERS");
229
230 let owners = Owners {
232 output: Some(OwnersOutput {
233 platform: Some(Platform::Gitlab),
234 path: None,
235 header: None,
236 }),
237 ..Default::default()
238 };
239 assert_eq!(owners.output_path(), "CODEOWNERS");
240
241 let owners = Owners {
243 output: Some(OwnersOutput {
244 platform: Some(Platform::Github),
245 path: Some("docs/CODEOWNERS".to_string()),
246 header: None,
247 }),
248 ..Default::default()
249 };
250 assert_eq!(owners.output_path(), "docs/CODEOWNERS");
251 }
252
253 #[test]
254 fn test_generate_simple() {
255 let owners = Owners {
256 rules: vec![
257 OwnerRule {
258 pattern: "*.rs".to_string(),
259 owners: vec!["@rust-team".to_string()],
260 description: None,
261 section: None,
262 },
263 OwnerRule {
264 pattern: "/docs/**".to_string(),
265 owners: vec!["@docs-team".to_string(), "@tech-writers".to_string()],
266 description: None,
267 section: None,
268 },
269 ],
270 ..Default::default()
271 };
272
273 let content = owners.generate();
274 assert!(content.contains("*.rs @rust-team"));
275 assert!(content.contains("/docs/** @docs-team @tech-writers"));
276 }
277
278 #[test]
279 fn test_generate_with_sections() {
280 let owners = Owners {
281 rules: vec![
282 OwnerRule {
283 pattern: "*.rs".to_string(),
284 owners: vec!["@backend".to_string()],
285 description: Some("Rust source files".to_string()),
286 section: Some("Backend".to_string()),
287 },
288 OwnerRule {
289 pattern: "*.ts".to_string(),
290 owners: vec!["@frontend".to_string()],
291 description: None,
292 section: Some("Frontend".to_string()),
293 },
294 ],
295 ..Default::default()
296 };
297
298 let content = owners.generate();
299 assert!(content.contains("# Backend"));
300 assert!(content.contains("# Rust source files"));
301 assert!(content.contains("# Frontend"));
302 }
303
304 #[test]
305 fn test_generate_with_default_owners() {
306 let owners = Owners {
307 default_owners: Some(vec!["@core-team".to_string()]),
308 rules: vec![OwnerRule {
309 pattern: "/security/**".to_string(),
310 owners: vec!["@security-team".to_string()],
311 description: None,
312 section: None,
313 }],
314 ..Default::default()
315 };
316
317 let content = owners.generate();
318 assert!(content.contains("* @core-team"));
319 assert!(content.contains("/security/** @security-team"));
320 }
321
322 #[test]
323 fn test_generate_with_custom_header() {
324 let owners = Owners {
325 output: Some(OwnersOutput {
326 platform: None,
327 path: None,
328 header: Some("Custom Header\nLine 2".to_string()),
329 }),
330 rules: vec![],
331 ..Default::default()
332 };
333
334 let content = owners.generate();
335 assert!(content.contains("# Custom Header"));
336 assert!(content.contains("# Line 2"));
337 }
338
339 #[test]
340 fn test_generate_gitlab_sections() {
341 let owners = Owners {
342 output: Some(OwnersOutput {
343 platform: Some(Platform::Gitlab),
344 path: None,
345 header: None,
346 }),
347 rules: vec![
348 OwnerRule {
349 pattern: "*.rs".to_string(),
350 owners: vec!["@backend".to_string()],
351 section: Some("Backend".to_string()),
352 description: None,
353 },
354 OwnerRule {
355 pattern: "*.ts".to_string(),
356 owners: vec!["@frontend".to_string()],
357 section: Some("Frontend".to_string()),
358 description: None,
359 },
360 ],
361 ..Default::default()
362 };
363
364 let content = owners.generate();
365 assert!(
367 content.contains("[Backend]"),
368 "GitLab should use [Section] syntax, got: {content}"
369 );
370 assert!(
371 content.contains("[Frontend]"),
372 "GitLab should use [Section] syntax, got: {content}"
373 );
374 assert!(
376 !content.contains("# Backend"),
377 "GitLab should NOT use # Section"
378 );
379 assert!(
380 !content.contains("# Frontend"),
381 "GitLab should NOT use # Section"
382 );
383 }
384
385 #[test]
386 fn test_generate_groups_rules_by_section() {
387 let owners = Owners {
389 rules: vec![
390 OwnerRule {
391 pattern: "*.rs".to_string(),
392 owners: vec!["@backend".to_string()],
393 section: Some("Backend".to_string()),
394 description: None,
395 },
396 OwnerRule {
397 pattern: "*.ts".to_string(),
398 owners: vec!["@frontend".to_string()],
399 section: Some("Frontend".to_string()),
400 description: None,
401 },
402 OwnerRule {
403 pattern: "*.go".to_string(),
404 owners: vec!["@backend".to_string()],
405 section: Some("Backend".to_string()),
406 description: None,
407 },
408 ],
409 ..Default::default()
410 };
411
412 let content = owners.generate();
413 let backend_count = content.matches("# Backend").count();
415 assert_eq!(
416 backend_count, 1,
417 "Backend section should appear exactly once, found {backend_count} times"
418 );
419 let backend_idx = content.find("# Backend").unwrap();
421 let rs_idx = content.find("*.rs").unwrap();
422 let go_idx = content.find("*.go").unwrap();
423 let frontend_idx = content.find("# Frontend").unwrap();
424 assert!(
426 rs_idx > backend_idx && rs_idx < frontend_idx,
427 "*.rs should be in Backend section"
428 );
429 assert!(
430 go_idx > backend_idx && go_idx < frontend_idx,
431 "*.go should be in Backend section"
432 );
433 }
434}