cuenv_codeowners/provider/mod.rs
1//! CODEOWNERS sync providers for different platforms.
2//!
3//! This module provides a trait-based abstraction for syncing CODEOWNERS files
4//! across different platforms (GitHub, GitLab, Bitbucket). Each platform has
5//! specific requirements for file location and section syntax.
6//!
7//! # Provider Implementations
8//!
9//! Provider implementations are available in separate platform crates:
10//! - `cuenv-github`: [`GitHubCodeownersProvider`](https://docs.rs/cuenv-github)
11//! - `cuenv-gitlab`: [`GitLabCodeownersProvider`](https://docs.rs/cuenv-gitlab)
12//! - `cuenv-bitbucket`: [`BitbucketCodeownersProvider`](https://docs.rs/cuenv-bitbucket)
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use cuenv_codeowners::provider::{CodeownersProvider, ProjectOwners};
18//! use cuenv_github::GitHubCodeownersProvider;
19//! use std::path::Path;
20//!
21//! let provider = GitHubCodeownersProvider;
22//! let projects = vec![/* ... */];
23//! let result = provider.sync(Path::new("."), &projects, false)?;
24//! ```
25
26use crate::{CodeownersBuilder, Platform, Rule};
27use std::fs;
28use std::io;
29use std::path::{Path, PathBuf};
30
31/// Error type for provider operations.
32#[derive(Debug)]
33pub enum ProviderError {
34 /// I/O error during file operations.
35 Io(io::Error),
36 /// Path validation error (e.g., path traversal attempt).
37 InvalidPath(String),
38 /// Configuration error.
39 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
67/// Result type for provider operations.
68pub type Result<T> = std::result::Result<T, ProviderError>;
69
70/// Project with its owners configuration and relative path.
71///
72/// Used to aggregate ownership rules from multiple projects in a workspace.
73#[derive(Debug, Clone)]
74pub struct ProjectOwners {
75 /// Relative path from repo root to project directory.
76 pub path: PathBuf,
77 /// Project name (used for section headers).
78 pub name: String,
79 /// Ownership rules for this project.
80 pub rules: Vec<Rule>,
81}
82
83impl ProjectOwners {
84 /// Create a new project owners configuration.
85 pub fn new(path: impl Into<PathBuf>, name: impl Into<String>, rules: Vec<Rule>) -> Self {
86 Self {
87 path: path.into(),
88 name: name.into(),
89 rules,
90 }
91 }
92}
93
94/// Status of a sync operation.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum SyncStatus {
97 /// File was created (didn't exist before).
98 Created,
99 /// File was updated (content changed).
100 Updated,
101 /// File is unchanged (content matches).
102 Unchanged,
103 /// Would create file (dry-run mode).
104 WouldCreate,
105 /// Would update file (dry-run mode).
106 WouldUpdate,
107}
108
109impl std::fmt::Display for SyncStatus {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 Self::Created => write!(f, "created"),
113 Self::Updated => write!(f, "updated"),
114 Self::Unchanged => write!(f, "unchanged"),
115 Self::WouldCreate => write!(f, "would create"),
116 Self::WouldUpdate => write!(f, "would update"),
117 }
118 }
119}
120
121/// Result of a sync operation.
122#[derive(Debug, Clone)]
123pub struct SyncResult {
124 /// Path where the file was written (or would be written).
125 pub path: PathBuf,
126 /// Status of the operation.
127 pub status: SyncStatus,
128 /// Generated content.
129 pub content: String,
130}
131
132/// Result of a check operation.
133#[derive(Debug, Clone)]
134pub struct CheckResult {
135 /// Path to the CODEOWNERS file.
136 pub path: PathBuf,
137 /// Whether the file is in sync with configuration.
138 pub in_sync: bool,
139 /// Expected content (from configuration).
140 pub expected: String,
141 /// Actual content (from file), if file exists.
142 pub actual: Option<String>,
143}
144
145/// Trait for CODEOWNERS sync providers.
146///
147/// Each platform (GitHub, GitLab, Bitbucket) implements this trait to provide
148/// platform-specific sync behavior.
149pub trait CodeownersProvider: Send + Sync {
150 /// Get the platform type.
151 fn platform(&self) -> Platform;
152
153 /// Sync CODEOWNERS from project configurations.
154 ///
155 /// Aggregates ownership rules from all projects and writes the appropriate
156 /// CODEOWNERS file(s) for this platform.
157 ///
158 /// # Arguments
159 ///
160 /// * `repo_root` - Root directory of the repository
161 /// * `projects` - List of projects with their ownership configurations
162 /// * `dry_run` - If true, don't write files, just report what would happen
163 ///
164 /// # Errors
165 ///
166 /// Returns an error if file operations fail or configuration is invalid.
167 fn sync(
168 &self,
169 repo_root: &Path,
170 projects: &[ProjectOwners],
171 dry_run: bool,
172 ) -> Result<SyncResult>;
173
174 /// Check if CODEOWNERS is in sync with configuration.
175 ///
176 /// # Arguments
177 ///
178 /// * `repo_root` - Root directory of the repository
179 /// * `projects` - List of projects with their ownership configurations
180 ///
181 /// # Errors
182 ///
183 /// Returns an error if file operations fail or configuration is invalid.
184 fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult>;
185}
186
187/// Prefix a pattern with the project's relative path.
188///
189/// This ensures patterns in nested projects correctly reference files
190/// from the repository root in the aggregated CODEOWNERS file.
191///
192/// # Examples
193///
194/// ```rust,ignore
195/// // Root project - patterns are normalized to start with /
196/// prefix_pattern("", "*.rs") -> "/*.rs"
197/// prefix_pattern(".", "/docs/**") -> "/docs/**"
198///
199/// // Nested project - patterns are prefixed with project path
200/// prefix_pattern("services/api", "*.rs") -> "/services/api/*.rs"
201/// prefix_pattern("services/api", "/src/**") -> "/services/api/src/**"
202/// ```
203pub fn prefix_pattern(project_path: &Path, pattern: &str) -> String {
204 let prefix = project_path.to_string_lossy();
205
206 // Root project (empty or "." path) - normalize to start with /
207 if prefix.is_empty() || prefix == "." {
208 if pattern.starts_with('/') {
209 pattern.to_string()
210 } else {
211 format!("/{pattern}")
212 }
213 }
214 // Nested project - prefix with project path
215 else if pattern.starts_with('/') {
216 // Pattern like "/src/**" becomes "/project/path/src/**"
217 format!("/{prefix}{pattern}")
218 } else {
219 // Pattern like "*.rs" becomes "/project/path/*.rs"
220 format!("/{prefix}/{pattern}")
221 }
222}
223
224/// Generate aggregated CODEOWNERS content from multiple projects.
225///
226/// This is the core aggregation logic used by all providers. Each provider
227/// can customize the output format (e.g., section syntax) by setting the
228/// platform on the builder.
229pub fn generate_aggregated_content(
230 platform: Platform,
231 projects: &[ProjectOwners],
232 header: Option<&str>,
233) -> String {
234 let mut builder = CodeownersBuilder::default().platform(platform);
235
236 // Set header
237 let default_header = "CODEOWNERS file - Generated by cuenv\n\
238 Do not edit manually. Run `cuenv sync codeowners -A` to regenerate.";
239 builder = builder.header(header.unwrap_or(default_header));
240
241 // Process each project
242 for project in projects {
243 // Add rules with prefixed patterns
244 for rule in &project.rules {
245 let prefixed_pattern = prefix_pattern(&project.path, &rule.pattern);
246 let mut new_rule = Rule::new(prefixed_pattern, rule.owners.clone());
247
248 // Use project name as section if rule doesn't have one
249 if let Some(ref section) = rule.section {
250 new_rule = new_rule.section(section.clone());
251 } else {
252 new_rule = new_rule.section(project.name.clone());
253 }
254
255 if let Some(ref description) = rule.description {
256 new_rule = new_rule.description(description.clone());
257 }
258
259 builder = builder.rule(new_rule);
260 }
261 }
262
263 builder.build().generate()
264}
265
266/// Write content to a file, creating parent directories as needed.
267///
268/// Returns the sync status based on whether the file was created, updated, or unchanged.
269pub fn write_codeowners_file(path: &Path, content: &str, dry_run: bool) -> Result<SyncStatus> {
270 let exists = path.exists();
271 let current_content = if exists {
272 Some(fs::read_to_string(path)?)
273 } else {
274 None
275 };
276
277 // Check if content matches (normalize line endings for comparison)
278 let normalize = |s: &str| -> String {
279 s.replace("\r\n", "\n")
280 .lines()
281 .map(str::trim_end)
282 .collect::<Vec<_>>()
283 .join("\n")
284 };
285
286 let content_matches = current_content
287 .as_ref()
288 .is_some_and(|current| normalize(current) == normalize(content));
289
290 if content_matches {
291 return Ok(SyncStatus::Unchanged);
292 }
293
294 if dry_run {
295 return Ok(if exists {
296 SyncStatus::WouldUpdate
297 } else {
298 SyncStatus::WouldCreate
299 });
300 }
301
302 // Create parent directories if needed
303 if let Some(parent) = path.parent()
304 && !parent.exists()
305 {
306 fs::create_dir_all(parent)?;
307 }
308
309 fs::write(path, content)?;
310
311 Ok(if exists {
312 SyncStatus::Updated
313 } else {
314 SyncStatus::Created
315 })
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_prefix_pattern_root_project() {
324 // Root project patterns should start with /
325 assert_eq!(prefix_pattern(Path::new(""), "*.rs"), "/*.rs");
326 assert_eq!(prefix_pattern(Path::new("."), "*.rs"), "/*.rs");
327 assert_eq!(prefix_pattern(Path::new(""), "/docs/**"), "/docs/**");
328 assert_eq!(prefix_pattern(Path::new("."), "/src/**"), "/src/**");
329 }
330
331 #[test]
332 fn test_prefix_pattern_nested_project() {
333 // Nested project patterns should be prefixed
334 assert_eq!(
335 prefix_pattern(Path::new("services/api"), "*.rs"),
336 "/services/api/*.rs"
337 );
338 assert_eq!(
339 prefix_pattern(Path::new("services/api"), "/src/**"),
340 "/services/api/src/**"
341 );
342 assert_eq!(
343 prefix_pattern(Path::new("libs/common"), "Cargo.toml"),
344 "/libs/common/Cargo.toml"
345 );
346 }
347
348 #[test]
349 fn test_generate_aggregated_content() {
350 let projects = vec![
351 ProjectOwners::new(
352 "services/api",
353 "services/api",
354 vec![Rule::new("*.rs", ["@backend-team"])],
355 ),
356 ProjectOwners::new(
357 "services/web",
358 "services/web",
359 vec![Rule::new("*.ts", ["@frontend-team"])],
360 ),
361 ];
362
363 let content = generate_aggregated_content(Platform::Github, &projects, None);
364
365 assert!(content.contains("/services/api/*.rs @backend-team"));
366 assert!(content.contains("/services/web/*.ts @frontend-team"));
367 assert!(content.contains("# services/api"));
368 assert!(content.contains("# services/web"));
369 }
370}