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    /// Default owners for all files in this project.
80    pub default_owners: Option<Vec<String>>,
81    /// Ownership rules for this project.
82    pub rules: Vec<Rule>,
83}
84
85impl ProjectOwners {
86    /// Create a new project owners configuration.
87    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    /// Set default owners for this project.
97    #[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/// Status of a sync operation.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum SyncStatus {
107    /// File was created (didn't exist before).
108    Created,
109    /// File was updated (content changed).
110    Updated,
111    /// File is unchanged (content matches).
112    Unchanged,
113    /// Would create file (dry-run mode).
114    WouldCreate,
115    /// Would update file (dry-run mode).
116    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/// Result of a sync operation.
132#[derive(Debug, Clone)]
133pub struct SyncResult {
134    /// Path where the file was written (or would be written).
135    pub path: PathBuf,
136    /// Status of the operation.
137    pub status: SyncStatus,
138    /// Generated content.
139    pub content: String,
140}
141
142/// Result of a check operation.
143#[derive(Debug, Clone)]
144pub struct CheckResult {
145    /// Path to the CODEOWNERS file.
146    pub path: PathBuf,
147    /// Whether the file is in sync with configuration.
148    pub in_sync: bool,
149    /// Expected content (from configuration).
150    pub expected: String,
151    /// Actual content (from file), if file exists.
152    pub actual: Option<String>,
153}
154
155/// Trait for CODEOWNERS sync providers.
156///
157/// Each platform (GitHub, GitLab, Bitbucket) implements this trait to provide
158/// platform-specific sync behavior.
159pub trait CodeownersProvider: Send + Sync {
160    /// Get the platform type.
161    fn platform(&self) -> Platform;
162
163    /// Sync CODEOWNERS from project configurations.
164    ///
165    /// Aggregates ownership rules from all projects and writes the appropriate
166    /// CODEOWNERS file(s) for this platform.
167    ///
168    /// # Arguments
169    ///
170    /// * `repo_root` - Root directory of the repository
171    /// * `projects` - List of projects with their ownership configurations
172    /// * `dry_run` - If true, don't write files, just report what would happen
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if file operations fail or configuration is invalid.
177    fn sync(
178        &self,
179        repo_root: &Path,
180        projects: &[ProjectOwners],
181        dry_run: bool,
182    ) -> Result<SyncResult>;
183
184    /// Check if CODEOWNERS is in sync with configuration.
185    ///
186    /// # Arguments
187    ///
188    /// * `repo_root` - Root directory of the repository
189    /// * `projects` - List of projects with their ownership configurations
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if file operations fail or configuration is invalid.
194    fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult>;
195}
196
197/// Prefix a pattern with the project's relative path.
198///
199/// This ensures patterns in nested projects correctly reference files
200/// from the repository root in the aggregated CODEOWNERS file.
201///
202/// # Examples
203///
204/// ```rust,ignore
205/// // Root project - patterns are normalized to start with /
206/// prefix_pattern("", "*.rs") -> "/*.rs"
207/// prefix_pattern(".", "/docs/**") -> "/docs/**"
208///
209/// // Nested project - patterns are prefixed with project path
210/// prefix_pattern("services/api", "*.rs") -> "/services/api/*.rs"
211/// prefix_pattern("services/api", "/src/**") -> "/services/api/src/**"
212/// ```
213pub fn prefix_pattern(project_path: &Path, pattern: &str) -> String {
214    let prefix = project_path.to_string_lossy();
215
216    // Root project (empty or "." path) - normalize to start with /
217    if prefix.is_empty() || prefix == "." {
218        if pattern.starts_with('/') {
219            pattern.to_string()
220        } else {
221            format!("/{pattern}")
222        }
223    }
224    // Nested project - prefix with project path
225    else if pattern.starts_with('/') {
226        // Pattern like "/src/**" becomes "/project/path/src/**"
227        format!("/{prefix}{pattern}")
228    } else {
229        // Pattern like "*.rs" becomes "/project/path/*.rs"
230        format!("/{prefix}/{pattern}")
231    }
232}
233
234/// Generate aggregated CODEOWNERS content from multiple projects.
235///
236/// This is the core aggregation logic used by all providers. Each provider
237/// can customize the output format (e.g., section syntax) by setting the
238/// platform on the builder.
239pub 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    // Set header
247    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    // Process each project
252    for project in projects {
253        // Add default owners for this project as a catch-all pattern
254        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        // Add rules with prefixed patterns
263        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            // Use project name as section if rule doesn't have one
268            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
285/// Write content to a file, creating parent directories as needed.
286///
287/// Returns the sync status based on whether the file was created, updated, or unchanged.
288pub 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    // Check if content matches (normalize line endings for comparison)
297    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    // Create parent directories if needed
322    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        // Root project patterns should start with /
344        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        // Nested project patterns should be prefixed
353        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}