Skip to main content

cuenv_codeowners/provider/
mod.rs

1//! CODEOWNERS sync providers.
2//!
3//! This module provides a trait-based abstraction for syncing CODEOWNERS files.
4//! Provider implementations live in platform crates (e.g., `cuenv-github`, `cuenv-gitlab`).
5//!
6//! # Provider Implementations
7//!
8//! - `cuenv-github`: GitHub CODEOWNERS (`.github/CODEOWNERS`, comment sections)
9//! - `cuenv-gitlab`: GitLab CODEOWNERS (`CODEOWNERS`, bracket sections)
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use cuenv_codeowners::provider::{CodeOwnersProvider, ProjectOwners};
15//! use cuenv_github::GitHubCodeOwnersProvider;
16//! use std::path::Path;
17//!
18//! let provider = GitHubCodeOwnersProvider;
19//! let projects = vec![/* ... */];
20//! let result = provider.sync(Path::new("."), &projects, false)?;
21//! ```
22
23use crate::{CodeOwnersBuilder, Rule, SectionStyle};
24use std::fs;
25use std::io;
26use std::path::{Path, PathBuf};
27
28/// Error type for provider operations.
29#[derive(Debug)]
30pub enum ProviderError {
31    /// I/O error during file operations.
32    Io(io::Error),
33    /// Path validation error (e.g., path traversal attempt).
34    InvalidPath(String),
35    /// Configuration error.
36    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
64/// Result type for provider operations.
65pub type Result<T> = std::result::Result<T, ProviderError>;
66
67/// Project with its owners configuration and relative path.
68///
69/// Used to aggregate ownership rules from multiple projects in a workspace.
70#[derive(Debug, Clone)]
71pub struct ProjectOwners {
72    /// Relative path from repo root to project directory.
73    pub path: PathBuf,
74    /// Project name (used for section headers).
75    pub name: String,
76    /// Ownership rules for this project.
77    pub rules: Vec<Rule>,
78}
79
80impl ProjectOwners {
81    /// Create a new project owners configuration.
82    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/// Status of a sync operation.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum SyncStatus {
94    /// File was created (didn't exist before).
95    Created,
96    /// File was updated (content changed).
97    Updated,
98    /// File is unchanged (content matches).
99    Unchanged,
100    /// Would create file (dry-run mode).
101    WouldCreate,
102    /// Would update file (dry-run mode).
103    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/// Result of a sync operation.
119#[derive(Debug, Clone)]
120pub struct SyncResult {
121    /// Path where the file was written (or would be written).
122    pub path: PathBuf,
123    /// Status of the operation.
124    pub status: SyncStatus,
125    /// Generated content.
126    pub content: String,
127}
128
129/// Result of a check operation.
130#[derive(Debug, Clone)]
131pub struct CheckResult {
132    /// Path to the CODEOWNERS file.
133    pub path: PathBuf,
134    /// Whether the file is in sync with configuration.
135    pub in_sync: bool,
136    /// Expected content (from configuration).
137    pub expected: String,
138    /// Actual content (from file), if file exists.
139    pub actual: Option<String>,
140}
141
142/// Trait for CODEOWNERS sync providers.
143///
144/// Each platform implements this trait to provide platform-specific sync behavior.
145/// Implementations determine the output path and section formatting style.
146pub trait CodeOwnersProvider: Send + Sync {
147    /// Get the output path for the CODEOWNERS file.
148    ///
149    /// Examples: `.github/CODEOWNERS` (GitHub), `CODEOWNERS` (GitLab)
150    fn output_path(&self) -> &str;
151
152    /// Get the section formatting style.
153    fn section_style(&self) -> SectionStyle;
154
155    /// Sync CODEOWNERS from project configurations.
156    ///
157    /// Aggregates ownership rules from all projects and writes the appropriate
158    /// CODEOWNERS file(s) for this platform.
159    ///
160    /// # Arguments
161    ///
162    /// * `repo_root` - Root directory of the repository
163    /// * `projects` - List of projects with their ownership configurations
164    /// * `dry_run` - If true, don't write files, just report what would happen
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if file operations fail or configuration is invalid.
169    fn sync(
170        &self,
171        repo_root: &Path,
172        projects: &[ProjectOwners],
173        dry_run: bool,
174    ) -> Result<SyncResult>;
175
176    /// Check if CODEOWNERS is in sync with configuration.
177    ///
178    /// # Arguments
179    ///
180    /// * `repo_root` - Root directory of the repository
181    /// * `projects` - List of projects with their ownership configurations
182    ///
183    /// # Errors
184    ///
185    /// Returns an error if file operations fail or configuration is invalid.
186    fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult>;
187}
188
189/// Prefix a pattern with the project's relative path.
190///
191/// This ensures patterns in nested projects correctly reference files
192/// from the repository root in the aggregated CODEOWNERS file.
193///
194/// # Examples
195///
196/// ```rust,ignore
197/// // Root project - patterns are normalized to start with /
198/// prefix_pattern("", "*.rs") -> "/*.rs"
199/// prefix_pattern(".", "/docs/**") -> "/docs/**"
200///
201/// // Nested project - patterns are prefixed with project path
202/// prefix_pattern("services/api", "*.rs") -> "/services/api/*.rs"
203/// prefix_pattern("services/api", "/src/**") -> "/services/api/src/**"
204/// ```
205pub fn prefix_pattern(project_path: &Path, pattern: &str) -> String {
206    let prefix = project_path.to_string_lossy();
207
208    // Root project (empty or "." path) - normalize to start with /
209    if prefix.is_empty() || prefix == "." {
210        if pattern.starts_with('/') {
211            pattern.to_string()
212        } else {
213            format!("/{pattern}")
214        }
215    }
216    // Nested project - prefix with project path
217    else if pattern.starts_with('/') {
218        // Pattern like "/src/**" becomes "/project/path/src/**"
219        format!("/{prefix}{pattern}")
220    } else {
221        // Pattern like "*.rs" becomes "/project/path/*.rs"
222        format!("/{prefix}/{pattern}")
223    }
224}
225
226/// Generate aggregated CODEOWNERS content from multiple projects.
227///
228/// This is the core aggregation logic used by all providers. Each provider
229/// specifies their section style for formatting.
230pub 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    // Set header
238    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    // Process each project
243    for project in projects {
244        // Add rules with prefixed patterns
245        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            // Use project name as section if rule doesn't have one
250            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
267/// Write content to a file, creating parent directories as needed.
268///
269/// Returns the sync status based on whether the file was created, updated, or unchanged.
270pub 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    // Check if content matches (normalize line endings for comparison)
279    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    // Create parent directories if needed
304    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        // Root project patterns should start with /
326        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        // Nested project patterns should be prefixed
335        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}