1#![warn(missing_docs)]
40
41pub mod provider;
42
43use std::collections::BTreeMap;
44use std::fmt;
45
46#[cfg(feature = "schemars")]
47use schemars::JsonSchema;
48#[cfg(feature = "serde")]
49use serde::{Deserialize, Serialize};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
58#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
59#[cfg_attr(feature = "schemars", derive(JsonSchema))]
60pub enum SectionStyle {
61 #[default]
63 Comment,
64 Bracket,
66 None,
68}
69
70impl fmt::Display for SectionStyle {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 SectionStyle::Comment => write!(f, "comment"),
74 SectionStyle::Bracket => write!(f, "bracket"),
75 SectionStyle::None => write!(f, "none"),
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
95#[cfg_attr(feature = "schemars", derive(JsonSchema))]
96pub struct Rule {
97 pub pattern: String,
99 pub owners: Vec<String>,
101 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
103 pub description: Option<String>,
104 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
106 pub section: Option<String>,
107}
108
109impl Rule {
110 pub fn new(
121 pattern: impl Into<String>,
122 owners: impl IntoIterator<Item = impl Into<String>>,
123 ) -> Self {
124 Self {
125 pattern: pattern.into(),
126 owners: owners.into_iter().map(Into::into).collect(),
127 description: None,
128 section: None,
129 }
130 }
131
132 #[must_use]
136 pub fn description(mut self, description: impl Into<String>) -> Self {
137 self.description = Some(description.into());
138 self
139 }
140
141 #[must_use]
145 pub fn section(mut self, section: impl Into<String>) -> Self {
146 self.section = Some(section.into());
147 self
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Default)]
170#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
171#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
172#[cfg_attr(feature = "schemars", derive(JsonSchema))]
173pub struct CodeOwners {
174 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
176 pub section_style: Option<SectionStyle>,
177 #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
179 pub header: Option<String>,
180 #[cfg_attr(feature = "serde", serde(default))]
182 pub rules: Vec<Rule>,
183}
184
185impl CodeOwners {
186 #[must_use]
198 pub fn builder() -> CodeOwnersBuilder {
199 CodeOwnersBuilder::default()
200 }
201
202 #[must_use]
217 pub fn generate(&self) -> String {
218 let mut output = String::new();
219 let section_style = self.section_style.unwrap_or_default();
220
221 if let Some(ref header) = self.header {
223 for line in header.lines() {
224 output.push_str("# ");
225 output.push_str(line);
226 output.push('\n');
227 }
228 output.push('\n');
229 }
230
231 let mut rules_by_section: BTreeMap<Option<&str>, Vec<&Rule>> = BTreeMap::new();
233 for rule in &self.rules {
234 rules_by_section
235 .entry(rule.section.as_deref())
236 .or_default()
237 .push(rule);
238 }
239
240 let mut first_section = true;
241 for (section, rules) in rules_by_section {
242 if !first_section {
243 output.push('\n');
244 }
245 first_section = false;
246
247 if let Some(section_name) = section {
249 match section_style {
250 SectionStyle::Bracket => {
251 output.push('[');
252 output.push_str(section_name);
253 output.push_str("]\n");
254 }
255 SectionStyle::Comment => {
256 output.push_str("# ");
257 output.push_str(section_name);
258 output.push('\n');
259 }
260 SectionStyle::None => {
261 }
263 }
264 }
265
266 for rule in rules {
268 if let Some(ref description) = rule.description {
269 output.push_str("# ");
270 output.push_str(description);
271 output.push('\n');
272 }
273
274 output.push_str(&rule.pattern);
275 output.push(' ');
276 output.push_str(&rule.owners.join(" "));
277 output.push('\n');
278 }
279 }
280
281 output
282 }
283}
284
285#[derive(Debug, Clone, Default)]
304pub struct CodeOwnersBuilder {
305 section_style: Option<SectionStyle>,
306 header: Option<String>,
307 rules: Vec<Rule>,
308}
309
310impl CodeOwnersBuilder {
311 #[must_use]
313 pub fn section_style(mut self, style: SectionStyle) -> Self {
314 self.section_style = Some(style);
315 self
316 }
317
318 #[must_use]
322 pub fn header(mut self, header: impl Into<String>) -> Self {
323 self.header = Some(header.into());
324 self
325 }
326
327 #[must_use]
329 pub fn rule(mut self, rule: Rule) -> Self {
330 self.rules.push(rule);
331 self
332 }
333
334 #[must_use]
336 pub fn rules(mut self, rules: impl IntoIterator<Item = Rule>) -> Self {
337 self.rules.extend(rules);
338 self
339 }
340
341 #[must_use]
343 pub fn build(self) -> CodeOwners {
344 CodeOwners {
345 section_style: self.section_style,
346 header: self.header,
347 rules: self.rules,
348 }
349 }
350}
351
352pub const DEFAULT_CUENV_HEADER: &str = "CODEOWNERS file - Generated by cuenv\nDo not edit manually. Configure in env.cue and run `cuenv owners sync`";
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_section_style_display() {
361 assert_eq!(SectionStyle::Comment.to_string(), "comment");
362 assert_eq!(SectionStyle::Bracket.to_string(), "bracket");
363 assert_eq!(SectionStyle::None.to_string(), "none");
364 }
365
366 #[test]
367 fn test_rule_builder() {
368 let rule = Rule::new("*.rs", ["@rust-team"])
369 .description("Rust files")
370 .section("Backend");
371
372 assert_eq!(rule.pattern, "*.rs");
373 assert_eq!(rule.owners, vec!["@rust-team"]);
374 assert_eq!(rule.description, Some("Rust files".to_string()));
375 assert_eq!(rule.section, Some("Backend".to_string()));
376 }
377
378 #[test]
379 fn test_generate_simple() {
380 let codeowners = CodeOwners::builder()
381 .rule(Rule::new("*.rs", ["@rust-team"]))
382 .rule(Rule::new("/docs/**", ["@docs-team", "@tech-writers"]))
383 .build();
384
385 let content = codeowners.generate();
386 assert!(content.contains("*.rs @rust-team"));
387 assert!(content.contains("/docs/** @docs-team @tech-writers"));
388 }
389
390 #[test]
391 fn test_generate_with_sections_comment_style() {
392 let codeowners = CodeOwners::builder()
393 .section_style(SectionStyle::Comment)
394 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
395 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
396 .build();
397
398 let content = codeowners.generate();
399 assert!(content.contains("# Backend"));
400 assert!(content.contains("# Frontend"));
401 }
402
403 #[test]
404 fn test_generate_with_custom_header() {
405 let codeowners = CodeOwners::builder()
406 .header("Custom Header\nLine 2")
407 .build();
408
409 let content = codeowners.generate();
410 assert!(content.contains("# Custom Header"));
411 assert!(content.contains("# Line 2"));
412 }
413
414 #[test]
415 fn test_generate_with_descriptions() {
416 let codeowners = CodeOwners::builder()
417 .rule(Rule::new("*.rs", ["@rust-team"]).description("Rust source files"))
418 .build();
419
420 let content = codeowners.generate();
421 assert!(content.contains("# Rust source files"));
422 assert!(content.contains("*.rs @rust-team"));
423 }
424
425 #[test]
426 fn test_generate_bracket_sections() {
427 let codeowners = CodeOwners::builder()
428 .section_style(SectionStyle::Bracket)
429 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
430 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
431 .build();
432
433 let content = codeowners.generate();
434 assert!(
436 content.contains("[Backend]"),
437 "Bracket style should use [Section] syntax, got: {content}"
438 );
439 assert!(
440 content.contains("[Frontend]"),
441 "Bracket style should use [Section] syntax, got: {content}"
442 );
443 assert!(
445 !content.contains("# Backend"),
446 "Bracket style should NOT use # Section"
447 );
448 assert!(
449 !content.contains("# Frontend"),
450 "Bracket style should NOT use # Section"
451 );
452 }
453
454 #[test]
455 fn test_generate_no_section_headers() {
456 let codeowners = CodeOwners::builder()
457 .section_style(SectionStyle::None)
458 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
459 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
460 .build();
461
462 let content = codeowners.generate();
463 assert!(
465 !content.contains("Backend"),
466 "SectionStyle::None should not include section headers"
467 );
468 assert!(
469 !content.contains("Frontend"),
470 "SectionStyle::None should not include section headers"
471 );
472 assert!(content.contains("*.rs @backend"));
474 assert!(content.contains("*.ts @frontend"));
475 }
476
477 #[test]
478 fn test_generate_groups_rules_by_section() {
479 let codeowners = CodeOwners::builder()
481 .section_style(SectionStyle::Comment)
482 .rule(Rule::new("*.rs", ["@backend"]).section("Backend"))
483 .rule(Rule::new("*.ts", ["@frontend"]).section("Frontend"))
484 .rule(Rule::new("*.go", ["@backend"]).section("Backend"))
485 .build();
486
487 let content = codeowners.generate();
488
489 let backend_count = content.matches("# Backend").count();
491 assert_eq!(
492 backend_count, 1,
493 "Backend section should appear exactly once, found {backend_count} times"
494 );
495
496 let backend_idx = content.find("# Backend").unwrap();
498 let rs_idx = content.find("*.rs").unwrap();
499 let go_idx = content.find("*.go").unwrap();
500 let frontend_idx = content.find("# Frontend").unwrap();
501
502 assert!(
503 rs_idx > backend_idx && rs_idx < frontend_idx,
504 "*.rs should be in Backend section"
505 );
506 assert!(
507 go_idx > backend_idx && go_idx < frontend_idx,
508 "*.go should be in Backend section"
509 );
510 }
511
512 #[test]
513 fn test_builder_chaining() {
514 let codeowners = CodeOwners::builder()
515 .section_style(SectionStyle::Comment)
516 .header("Code ownership")
517 .rule(Rule::new("*.rs", ["@rust"]))
518 .rules([
519 Rule::new("*.ts", ["@typescript"]),
520 Rule::new("*.py", ["@python"]),
521 ])
522 .build();
523
524 assert_eq!(codeowners.section_style, Some(SectionStyle::Comment));
525 assert_eq!(codeowners.header, Some("Code ownership".to_string()));
526 assert_eq!(codeowners.rules.len(), 3);
527 }
528}