cuenv_codeowners/provider/
mod.rs1use crate::{CodeownersBuilder, Platform, Rule};
27use std::fs;
28use std::io;
29use std::path::{Path, PathBuf};
30
31#[derive(Debug)]
33pub enum ProviderError {
34 Io(io::Error),
36 InvalidPath(String),
38 Configuration(String),
40}
41
42impl std::fmt::Display for ProviderError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Self::Io(e) => write!(f, "I/O error: {e}"),
46 Self::InvalidPath(msg) => write!(f, "Invalid path: {msg}"),
47 Self::Configuration(msg) => write!(f, "Configuration error: {msg}"),
48 }
49 }
50}
51
52impl std::error::Error for ProviderError {
53 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
54 match self {
55 Self::Io(e) => Some(e),
56 _ => None,
57 }
58 }
59}
60
61impl From<io::Error> for ProviderError {
62 fn from(e: io::Error) -> Self {
63 Self::Io(e)
64 }
65}
66
67pub type Result<T> = std::result::Result<T, ProviderError>;
69
70#[derive(Debug, Clone)]
74pub struct ProjectOwners {
75 pub path: PathBuf,
77 pub name: String,
79 pub default_owners: Option<Vec<String>>,
81 pub rules: Vec<Rule>,
83}
84
85impl ProjectOwners {
86 pub fn new(path: impl Into<PathBuf>, name: impl Into<String>, rules: Vec<Rule>) -> Self {
88 Self {
89 path: path.into(),
90 name: name.into(),
91 default_owners: None,
92 rules,
93 }
94 }
95
96 #[must_use]
98 pub fn with_default_owners(mut self, owners: Vec<String>) -> Self {
99 self.default_owners = Some(owners);
100 self
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum SyncStatus {
107 Created,
109 Updated,
111 Unchanged,
113 WouldCreate,
115 WouldUpdate,
117}
118
119impl std::fmt::Display for SyncStatus {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 Self::Created => write!(f, "created"),
123 Self::Updated => write!(f, "updated"),
124 Self::Unchanged => write!(f, "unchanged"),
125 Self::WouldCreate => write!(f, "would create"),
126 Self::WouldUpdate => write!(f, "would update"),
127 }
128 }
129}
130
131#[derive(Debug, Clone)]
133pub struct SyncResult {
134 pub path: PathBuf,
136 pub status: SyncStatus,
138 pub content: String,
140}
141
142#[derive(Debug, Clone)]
144pub struct CheckResult {
145 pub path: PathBuf,
147 pub in_sync: bool,
149 pub expected: String,
151 pub actual: Option<String>,
153}
154
155pub trait CodeownersProvider: Send + Sync {
160 fn platform(&self) -> Platform;
162
163 fn sync(
178 &self,
179 repo_root: &Path,
180 projects: &[ProjectOwners],
181 dry_run: bool,
182 ) -> Result<SyncResult>;
183
184 fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult>;
195}
196
197pub fn prefix_pattern(project_path: &Path, pattern: &str) -> String {
214 let prefix = project_path.to_string_lossy();
215
216 if prefix.is_empty() || prefix == "." {
218 if pattern.starts_with('/') {
219 pattern.to_string()
220 } else {
221 format!("/{pattern}")
222 }
223 }
224 else if pattern.starts_with('/') {
226 format!("/{prefix}{pattern}")
228 } else {
229 format!("/{prefix}/{pattern}")
231 }
232}
233
234pub fn generate_aggregated_content(
240 platform: Platform,
241 projects: &[ProjectOwners],
242 header: Option<&str>,
243) -> String {
244 let mut builder = CodeownersBuilder::default().platform(platform);
245
246 let default_header = "CODEOWNERS file - Generated by cuenv\n\
248 Do not edit manually. Run `cuenv sync codeowners -A` to regenerate.";
249 builder = builder.header(header.unwrap_or(default_header));
250
251 for project in projects {
253 if let Some(ref default_owners) = project.default_owners
255 && !default_owners.is_empty()
256 {
257 let pattern = prefix_pattern(&project.path, "**");
258 let rule = Rule::new(pattern, default_owners.clone()).section(project.name.clone());
259 builder = builder.rule(rule);
260 }
261
262 for rule in &project.rules {
264 let prefixed_pattern = prefix_pattern(&project.path, &rule.pattern);
265 let mut new_rule = Rule::new(prefixed_pattern, rule.owners.clone());
266
267 if let Some(ref section) = rule.section {
269 new_rule = new_rule.section(section.clone());
270 } else {
271 new_rule = new_rule.section(project.name.clone());
272 }
273
274 if let Some(ref description) = rule.description {
275 new_rule = new_rule.description(description.clone());
276 }
277
278 builder = builder.rule(new_rule);
279 }
280 }
281
282 builder.build().generate()
283}
284
285pub fn write_codeowners_file(path: &Path, content: &str, dry_run: bool) -> Result<SyncStatus> {
289 let exists = path.exists();
290 let current_content = if exists {
291 Some(fs::read_to_string(path)?)
292 } else {
293 None
294 };
295
296 let normalize = |s: &str| -> String {
298 s.replace("\r\n", "\n")
299 .lines()
300 .map(str::trim_end)
301 .collect::<Vec<_>>()
302 .join("\n")
303 };
304
305 let content_matches = current_content
306 .as_ref()
307 .is_some_and(|current| normalize(current) == normalize(content));
308
309 if content_matches {
310 return Ok(SyncStatus::Unchanged);
311 }
312
313 if dry_run {
314 return Ok(if exists {
315 SyncStatus::WouldUpdate
316 } else {
317 SyncStatus::WouldCreate
318 });
319 }
320
321 if let Some(parent) = path.parent()
323 && !parent.exists()
324 {
325 fs::create_dir_all(parent)?;
326 }
327
328 fs::write(path, content)?;
329
330 Ok(if exists {
331 SyncStatus::Updated
332 } else {
333 SyncStatus::Created
334 })
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn test_prefix_pattern_root_project() {
343 assert_eq!(prefix_pattern(Path::new(""), "*.rs"), "/*.rs");
345 assert_eq!(prefix_pattern(Path::new("."), "*.rs"), "/*.rs");
346 assert_eq!(prefix_pattern(Path::new(""), "/docs/**"), "/docs/**");
347 assert_eq!(prefix_pattern(Path::new("."), "/src/**"), "/src/**");
348 }
349
350 #[test]
351 fn test_prefix_pattern_nested_project() {
352 assert_eq!(
354 prefix_pattern(Path::new("services/api"), "*.rs"),
355 "/services/api/*.rs"
356 );
357 assert_eq!(
358 prefix_pattern(Path::new("services/api"), "/src/**"),
359 "/services/api/src/**"
360 );
361 assert_eq!(
362 prefix_pattern(Path::new("libs/common"), "Cargo.toml"),
363 "/libs/common/Cargo.toml"
364 );
365 }
366
367 #[test]
368 fn test_generate_aggregated_content() {
369 let projects = vec![
370 ProjectOwners::new(
371 "services/api",
372 "services/api",
373 vec![Rule::new("*.rs", ["@backend-team"])],
374 ),
375 ProjectOwners::new(
376 "services/web",
377 "services/web",
378 vec![Rule::new("*.ts", ["@frontend-team"])],
379 ),
380 ];
381
382 let content = generate_aggregated_content(Platform::Github, &projects, None);
383
384 assert!(content.contains("/services/api/*.rs @backend-team"));
385 assert!(content.contains("/services/web/*.ts @frontend-team"));
386 assert!(content.contains("# services/api"));
387 assert!(content.contains("# services/web"));
388 }
389}