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