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}