Skip to main content

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