cuenv_bitbucket/
codeowners.rs

1//! Bitbucket CODEOWNERS provider.
2//!
3//! Bitbucket supports CODEOWNERS files at:
4//! - `CODEOWNERS` (repository root)
5//!
6//! Bitbucket uses `# Section` comment syntax for sections (same as GitHub).
7//!
8//! This provider aggregates all project ownership rules into a single file
9//! at the repository root `CODEOWNERS`.
10
11use cuenv_codeowners::Platform;
12use cuenv_codeowners::provider::{
13    CheckResult, CodeownersProvider, ProjectOwners, ProviderError, Result, SyncResult,
14    generate_aggregated_content, write_codeowners_file,
15};
16use std::fs;
17use std::path::Path;
18
19/// Bitbucket CODEOWNERS provider.
20///
21/// Writes a single aggregated CODEOWNERS file to the repository root.
22/// Uses comment-style `# Section` syntax for grouping rules.
23#[derive(Debug, Clone, Copy, Default)]
24pub struct BitbucketCodeownersProvider;
25
26impl CodeownersProvider for BitbucketCodeownersProvider {
27    fn platform(&self) -> Platform {
28        Platform::Bitbucket
29    }
30
31    fn sync(
32        &self,
33        repo_root: &Path,
34        projects: &[ProjectOwners],
35        dry_run: bool,
36    ) -> Result<SyncResult> {
37        if projects.is_empty() {
38            return Err(ProviderError::Configuration(
39                "No projects with ownership configuration provided".to_string(),
40            ));
41        }
42
43        // Generate aggregated content with Bitbucket platform
44        let content = generate_aggregated_content(Platform::Bitbucket, projects, None);
45
46        // Output path is at repo root for Bitbucket
47        let output_path = repo_root.join("CODEOWNERS");
48
49        // Write the file
50        let status = write_codeowners_file(&output_path, &content, dry_run)?;
51
52        Ok(SyncResult {
53            path: output_path,
54            status,
55            content,
56        })
57    }
58
59    fn check(&self, repo_root: &Path, projects: &[ProjectOwners]) -> Result<CheckResult> {
60        if projects.is_empty() {
61            return Err(ProviderError::Configuration(
62                "No projects with ownership configuration provided".to_string(),
63            ));
64        }
65
66        // Generate expected content
67        let expected = generate_aggregated_content(Platform::Bitbucket, projects, None);
68
69        let output_path = repo_root.join("CODEOWNERS");
70
71        // Read actual content if file exists
72        let actual = if output_path.exists() {
73            Some(fs::read_to_string(&output_path)?)
74        } else {
75            None
76        };
77
78        // Compare (normalize line endings)
79        let normalize = |s: &str| -> String {
80            s.replace("\r\n", "\n")
81                .lines()
82                .map(str::trim_end)
83                .collect::<Vec<_>>()
84                .join("\n")
85        };
86
87        let in_sync = actual
88            .as_ref()
89            .is_some_and(|a| normalize(a) == normalize(&expected));
90
91        Ok(CheckResult {
92            path: output_path,
93            in_sync,
94            expected,
95            actual,
96        })
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use cuenv_codeowners::Rule;
104    use cuenv_codeowners::provider::SyncStatus;
105    use tempfile::tempdir;
106
107    #[test]
108    fn test_bitbucket_provider_platform() {
109        let provider = BitbucketCodeownersProvider;
110        assert_eq!(provider.platform(), Platform::Bitbucket);
111    }
112
113    #[test]
114    fn test_bitbucket_sync_creates_file() {
115        let temp = tempdir().unwrap();
116        let provider = BitbucketCodeownersProvider;
117
118        let projects = vec![ProjectOwners::new(
119            "services/api",
120            "services/api",
121            vec![Rule::new("*.rs", ["@backend-team"])],
122        )];
123
124        let result = provider.sync(temp.path(), &projects, false).unwrap();
125
126        assert_eq!(result.status, SyncStatus::Created);
127        // Bitbucket uses CODEOWNERS at repo root
128        assert!(result.path.ends_with("CODEOWNERS"));
129        assert!(!result.path.to_string_lossy().contains(".github"));
130        assert!(result.content.contains("/services/api/*.rs @backend-team"));
131
132        // Verify file was written
133        let file_content = fs::read_to_string(&result.path).unwrap();
134        assert_eq!(file_content, result.content);
135    }
136
137    #[test]
138    fn test_bitbucket_uses_comment_section_syntax() {
139        let temp = tempdir().unwrap();
140        let provider = BitbucketCodeownersProvider;
141
142        let projects = vec![
143            ProjectOwners::new(
144                "services/api",
145                "services/api",
146                vec![Rule::new("*.rs", ["@backend-team"])],
147            ),
148            ProjectOwners::new(
149                "services/web",
150                "services/web",
151                vec![Rule::new("*.ts", ["@frontend-team"])],
152            ),
153        ];
154
155        let result = provider.sync(temp.path(), &projects, false).unwrap();
156
157        // Bitbucket uses # Section syntax (like GitHub, unlike GitLab's [Section])
158        assert!(
159            result.content.contains("# services/api"),
160            "Should use # Section syntax, got:\n{}",
161            result.content
162        );
163        assert!(
164            result.content.contains("# services/web"),
165            "Should use # Section syntax"
166        );
167        // Should NOT use GitLab-style [Section] syntax
168        assert!(
169            !result.content.contains("[services/api]"),
170            "Should NOT use [Section] syntax"
171        );
172    }
173
174    #[test]
175    fn test_bitbucket_sync_dry_run() {
176        let temp = tempdir().unwrap();
177        let provider = BitbucketCodeownersProvider;
178
179        let projects = vec![ProjectOwners::new(
180            "services/api",
181            "services/api",
182            vec![Rule::new("*.rs", ["@backend-team"])],
183        )];
184
185        let result = provider.sync(temp.path(), &projects, true).unwrap();
186
187        assert_eq!(result.status, SyncStatus::WouldCreate);
188        assert!(!result.path.exists());
189    }
190
191    #[test]
192    fn test_bitbucket_check_in_sync() {
193        let temp = tempdir().unwrap();
194        let provider = BitbucketCodeownersProvider;
195
196        let projects = vec![ProjectOwners::new(
197            "services/api",
198            "services/api",
199            vec![Rule::new("*.rs", ["@backend-team"])],
200        )];
201
202        // Sync first
203        provider.sync(temp.path(), &projects, false).unwrap();
204
205        // Check should report in sync
206        let result = provider.check(temp.path(), &projects).unwrap();
207        assert!(result.in_sync);
208    }
209
210    #[test]
211    fn test_bitbucket_check_out_of_sync() {
212        let temp = tempdir().unwrap();
213        let provider = BitbucketCodeownersProvider;
214
215        // Create file with different content
216        fs::write(temp.path().join("CODEOWNERS"), "# Different content\n").unwrap();
217
218        let projects = vec![ProjectOwners::new(
219            "services/api",
220            "services/api",
221            vec![Rule::new("*.rs", ["@backend-team"])],
222        )];
223
224        let result = provider.check(temp.path(), &projects).unwrap();
225        assert!(!result.in_sync);
226    }
227
228    #[test]
229    fn test_bitbucket_empty_projects_error() {
230        let temp = tempdir().unwrap();
231        let provider = BitbucketCodeownersProvider;
232
233        let result = provider.sync(temp.path(), &[], false);
234        assert!(result.is_err());
235    }
236}