features_cli/
codeowners.rs

1use anyhow::Result;
2use std::fs;
3use std::io::{BufRead, BufReader, Write};
4use std::path::Path;
5
6use crate::models::Feature;
7
8const SECTION_START: &str = "# ==== GENERATED BY FEATURES-CLI ====";
9const SECTION_END: &str = "# ==== END SECTION GENERATED BY FEATURES-CLI ====";
10
11/// Generate or update a CODEOWNERS file with feature ownership information.
12///
13/// # Arguments
14///
15/// * `features` - List of features to generate CODEOWNERS entries for
16/// * `base_path` - Base path of the project (where features are located)
17/// * `project_dir` - Optional project directory to calculate relative paths
18/// * `output_dir` - Directory where the CODEOWNERS file should be created
19/// * `codeowners_path_override` - Optional custom path and filename for CODEOWNERS file
20/// * `owner_prefix` - Prefix to add to owner names (default: "@")
21///
22/// # Behavior
23///
24/// - Creates CODEOWNERS if it doesn't exist
25/// - Updates existing CODEOWNERS while preserving custom content
26/// - Generated content is placed between section markers
27/// - Content outside markers is preserved
28/// - Paths are normalized with forward slashes and leading `/`
29/// - Owners automatically get the specified prefix if not already present
30pub fn generate_codeowners(
31    features: &[Feature],
32    base_path: &Path,
33    project_dir: Option<&Path>,
34    output_dir: &Path,
35    codeowners_path_override: Option<&Path>,
36    owner_prefix: &str,
37) -> Result<()> {
38    let codeowners_path = if let Some(custom_path) = codeowners_path_override {
39        custom_path.to_path_buf()
40    } else {
41        output_dir.join("CODEOWNERS")
42    };
43
44    // Read existing file content if it exists
45    let (before_section, after_section) = if codeowners_path.exists() {
46        read_existing_codeowners(&codeowners_path)?
47    } else {
48        (String::new(), String::new())
49    };
50
51    // Generate the CODEOWNERS entries
52    let mut entries = Vec::new();
53    collect_entries(features, base_path, project_dir, &mut entries, owner_prefix);
54
55    // Write the CODEOWNERS file
56    write_codeowners_file(&codeowners_path, &before_section, &entries, &after_section)?;
57
58    eprintln!(
59        "✅ CODEOWNERS file generated at: {}",
60        codeowners_path.display()
61    );
62    Ok(())
63}
64
65/// Read an existing CODEOWNERS file and extract content before and after the generated section
66fn read_existing_codeowners(codeowners_path: &Path) -> Result<(String, String)> {
67    let file = fs::File::open(codeowners_path)?;
68    let reader = BufReader::new(file);
69    let mut lines = Vec::new();
70
71    for line in reader.lines() {
72        lines.push(line?);
73    }
74
75    // Find the section markers
76    let start_idx = lines.iter().position(|l| l.trim() == SECTION_START);
77    let end_idx = lines.iter().position(|l| l.trim() == SECTION_END);
78
79    match (start_idx, end_idx) {
80        (Some(start), Some(end)) if start < end => {
81            // Extract content before and after the generated section
82            let before = lines[..start].join("\n");
83            let after = if end + 1 < lines.len() {
84                lines[end + 1..].join("\n")
85            } else {
86                String::new()
87            };
88            Ok((before, after))
89        }
90        _ => {
91            // No valid section found, keep all content as "before"
92            Ok((lines.join("\n"), String::new()))
93        }
94    }
95}
96
97/// Recursively collect CODEOWNERS entries from features
98fn collect_entries(
99    features: &[Feature],
100    base_path: &Path,
101    project_dir: Option<&Path>,
102    entries: &mut Vec<String>,
103    owner_prefix: &str,
104) {
105    collect_entries_with_parent(
106        features,
107        base_path,
108        project_dir,
109        entries,
110        None,
111        owner_prefix,
112    );
113}
114
115/// Recursively collect CODEOWNERS entries from features with parent owner tracking
116fn collect_entries_with_parent(
117    features: &[Feature],
118    base_path: &Path,
119    project_dir: Option<&Path>,
120    entries: &mut Vec<String>,
121    parent_owner: Option<&str>,
122    owner_prefix: &str,
123) {
124    for feature in features {
125        // Skip this feature if it has the same owner as its parent
126        let should_skip = parent_owner.is_some_and(|parent| feature.owner == parent);
127
128        if !should_skip {
129            // Calculate the path for CODEOWNERS
130            let feature_path = std::path::PathBuf::from(&feature.path);
131            let full_path = base_path.join(&feature_path);
132
133            // Determine the final path to write
134            let codeowners_path = if let Some(proj_dir) = project_dir {
135                // Remove project_dir from the full_path if present
136                if let Ok(relative) = full_path.strip_prefix(proj_dir) {
137                    relative.to_path_buf()
138                } else {
139                    full_path
140                }
141            } else {
142                full_path
143            };
144
145            // Convert to forward slashes and add leading slash for CODEOWNERS format
146            let path_str = codeowners_path.to_str().unwrap_or("").replace('\\', "/");
147
148            let path_str = if path_str.starts_with('/') {
149                path_str
150            } else {
151                format!("/{}", path_str)
152            };
153
154            // Add owner with prefix, but skip if owner is empty
155            if feature.owner.is_empty() {
156                let unknown_owner = format_owner("Unknown", owner_prefix);
157                entries.push(format!("{} {}", path_str, unknown_owner));
158            } else {
159                let owner = format_owner(&feature.owner, owner_prefix);
160                entries.push(format!("{} {}", path_str, owner));
161            }
162        }
163
164        // Recursively collect from nested features, passing current feature's owner
165        collect_entries_with_parent(
166            &feature.features,
167            base_path,
168            project_dir,
169            entries,
170            Some(&feature.owner),
171            owner_prefix,
172        );
173    }
174}
175
176/// Format owner with specified prefix if not already present
177fn format_owner(owner: &str, prefix: &str) -> String {
178    if prefix.is_empty() || owner.starts_with(prefix) {
179        owner.to_string()
180    } else {
181        format!("{}{}", prefix, owner)
182    }
183}
184
185/// Write the CODEOWNERS file with preserved content and generated section
186fn write_codeowners_file(
187    codeowners_path: &Path,
188    before_section: &str,
189    entries: &[String],
190    after_section: &str,
191) -> Result<()> {
192    let mut file = fs::File::create(codeowners_path)?;
193
194    // Write content before the generated section
195    if !before_section.is_empty() {
196        writeln!(file, "{}", before_section)?;
197        if !before_section.ends_with('\n') {
198            writeln!(file)?;
199        }
200    }
201
202    // Write the generated section
203    writeln!(file, "{}", SECTION_START)?;
204    for entry in entries {
205        writeln!(file, "{}", entry)?;
206    }
207    writeln!(file, "{}", SECTION_END)?;
208
209    // Write content after the generated section
210    if !after_section.is_empty() {
211        if !after_section.starts_with('\n') {
212            writeln!(file)?;
213        }
214        write!(file, "{}", after_section)?;
215        if !after_section.ends_with('\n') {
216            writeln!(file)?;
217        }
218    }
219
220    Ok(())
221}