cuenv_codeowners/provider/
mod.rs1use crate::{CodeOwnersBuilder, Rule, SectionStyle};
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28#[derive(Debug)]
30pub enum ProviderError {
31 Io(io::Error),
33 InvalidPath(String),
35 Configuration(String),
37}
38
39impl std::fmt::Display for ProviderError {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 match self {
42 Self::Io(e) => write!(f, "I/O error: {e}"),
43 Self::InvalidPath(msg) => write!(f, "Invalid path: {msg}"),
44 Self::Configuration(msg) => write!(f, "Configuration error: {msg}"),
45 }
46 }
47}
48
49impl std::error::Error for ProviderError {
50 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51 match self {
52 Self::Io(e) => Some(e),
53 _ => None,
54 }
55 }
56}
57
58impl From<io::Error> for ProviderError {
59 fn from(e: io::Error) -> Self {
60 Self::Io(e)
61 }
62}
63
64pub type Result<T> = std::result::Result<T, ProviderError>;
66
67#[derive(Debug, Clone)]
71pub struct ProjectOwners {
72 pub path: PathBuf,
74 pub name: String,
76 pub rules: Vec<Rule>,
78}
79
80impl ProjectOwners {
81 pub fn new(path: impl Into<PathBuf>, name: impl Into<String>, rules: Vec<Rule>) -> Self {
83 Self {
84 path: path.into(),
85 name: name.into(),
86 rules,
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SyncStatus {
94 Created,
96 Updated,
98 Unchanged,
100 WouldCreate,
102 WouldUpdate,
104}
105
106impl std::fmt::Display for SyncStatus {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 match self {
109 Self::Created => write!(f, "created"),
110 Self::Updated => write!(f, "updated"),
111 Self::Unchanged => write!(f, "unchanged"),
112 Self::WouldCreate => write!(f, "would create"),
113 Self::WouldUpdate => write!(f, "would update"),
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct SyncResult {
121 pub path: PathBuf,
123 pub status: SyncStatus,
125 pub content: String,
127}
128
129#[derive(Debug, Clone)]
131pub struct CheckResult {
132 pub path: PathBuf,
134 pub in_sync: bool,
136 pub expected: String,
138 pub actual: Option<String>,
140}
141
142pub trait CodeOwnersProvider: Send + Sync {
147 fn output_path(&self) -> &str;
151
152 fn section_style(&self) -> SectionStyle;
154
155 fn sync(
170 &self,
171 repo_root: &Path,
172 projects: &[ProjectOwners],
173 dry_run: bool,
174 ) -> Result<SyncResult>;
175
176 fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult>;
187}
188
189pub fn prefix_pattern(project_path: &Path, pattern: &str) -> String {
206 let prefix = project_path.to_string_lossy();
207
208 if prefix.is_empty() || prefix == "." {
210 if pattern.starts_with('/') {
211 pattern.to_string()
212 } else {
213 format!("/{pattern}")
214 }
215 }
216 else if pattern.starts_with('/') {
218 format!("/{prefix}{pattern}")
220 } else {
221 format!("/{prefix}/{pattern}")
223 }
224}
225
226pub fn generate_aggregated_content(
231 section_style: SectionStyle,
232 projects: &[ProjectOwners],
233 header: Option<&str>,
234) -> String {
235 let mut builder = CodeOwnersBuilder::default().section_style(section_style);
236
237 let default_header = "CODEOWNERS file - Generated by cuenv\n\
239 Do not edit manually. Run `cuenv sync codeowners -A` to regenerate.";
240 builder = builder.header(header.unwrap_or(default_header));
241
242 for project in projects {
244 for rule in &project.rules {
246 let prefixed_pattern = prefix_pattern(&project.path, &rule.pattern);
247 let mut new_rule = Rule::new(prefixed_pattern, rule.owners.clone());
248
249 if let Some(ref section) = rule.section {
251 new_rule = new_rule.section(section.clone());
252 } else {
253 new_rule = new_rule.section(project.name.clone());
254 }
255
256 if let Some(ref description) = rule.description {
257 new_rule = new_rule.description(description.clone());
258 }
259
260 builder = builder.rule(new_rule);
261 }
262 }
263
264 builder.build().generate()
265}
266
267pub fn write_codeowners_file(path: &Path, content: &str, dry_run: bool) -> Result<SyncStatus> {
271 let exists = path.exists();
272 let current_content = if exists {
273 Some(fs::read_to_string(path)?)
274 } else {
275 None
276 };
277
278 let normalize = |s: &str| -> String {
280 s.replace("\r\n", "\n")
281 .lines()
282 .map(str::trim_end)
283 .collect::<Vec<_>>()
284 .join("\n")
285 };
286
287 let content_matches = current_content
288 .as_ref()
289 .is_some_and(|current| normalize(current) == normalize(content));
290
291 if content_matches {
292 return Ok(SyncStatus::Unchanged);
293 }
294
295 if dry_run {
296 return Ok(if exists {
297 SyncStatus::WouldUpdate
298 } else {
299 SyncStatus::WouldCreate
300 });
301 }
302
303 if let Some(parent) = path.parent()
305 && !parent.exists()
306 {
307 fs::create_dir_all(parent)?;
308 }
309
310 fs::write(path, content)?;
311
312 Ok(if exists {
313 SyncStatus::Updated
314 } else {
315 SyncStatus::Created
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn test_prefix_pattern_root_project() {
325 assert_eq!(prefix_pattern(Path::new(""), "*.rs"), "/*.rs");
327 assert_eq!(prefix_pattern(Path::new("."), "*.rs"), "/*.rs");
328 assert_eq!(prefix_pattern(Path::new(""), "/docs/**"), "/docs/**");
329 assert_eq!(prefix_pattern(Path::new("."), "/src/**"), "/src/**");
330 }
331
332 #[test]
333 fn test_prefix_pattern_nested_project() {
334 assert_eq!(
336 prefix_pattern(Path::new("services/api"), "*.rs"),
337 "/services/api/*.rs"
338 );
339 assert_eq!(
340 prefix_pattern(Path::new("services/api"), "/src/**"),
341 "/services/api/src/**"
342 );
343 assert_eq!(
344 prefix_pattern(Path::new("libs/common"), "Cargo.toml"),
345 "/libs/common/Cargo.toml"
346 );
347 }
348
349 #[test]
350 fn test_generate_aggregated_content() {
351 let projects = vec![
352 ProjectOwners::new(
353 "services/api",
354 "services/api",
355 vec![Rule::new("*.rs", ["@backend-team"])],
356 ),
357 ProjectOwners::new(
358 "services/web",
359 "services/web",
360 vec![Rule::new("*.ts", ["@frontend-team"])],
361 ),
362 ];
363
364 let content = generate_aggregated_content(SectionStyle::Comment, &projects, None);
365
366 assert!(content.contains("/services/api/*.rs @backend-team"));
367 assert!(content.contains("/services/web/*.ts @frontend-team"));
368 assert!(content.contains("# services/api"));
369 assert!(content.contains("# services/web"));
370 }
371
372 #[test]
373 fn test_generate_aggregated_content_bracket_style() {
374 let projects = vec![ProjectOwners::new(
375 "services/api",
376 "services/api",
377 vec![Rule::new("*.rs", ["@backend-team"])],
378 )];
379
380 let content = generate_aggregated_content(SectionStyle::Bracket, &projects, None);
381
382 assert!(content.contains("/services/api/*.rs @backend-team"));
383 assert!(content.contains("[services/api]"));
384 assert!(!content.contains("# services/api"));
385 }
386}