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    for feature in features {
97        // Calculate the path for CODEOWNERS
98        let feature_path = std::path::PathBuf::from(&feature.path);
99        let full_path = base_path.join(&feature_path);
100
101        // Determine the final path to write
102        let codeowners_path = if let Some(proj_dir) = project_dir {
103            // Remove project_dir from the full_path if present
104            if let Ok(relative) = full_path.strip_prefix(proj_dir) {
105                relative.to_path_buf()
106            } else {
107                full_path
108            }
109        } else {
110            full_path
111        };
112
113        // Convert to forward slashes and add leading slash for CODEOWNERS format
114        let path_str = codeowners_path.to_str().unwrap_or("").replace('\\', "/");
115
116        let path_str = if path_str.starts_with('/') {
117            path_str
118        } else {
119            format!("/{}", path_str)
120        };
121
122        // Add owner with @ prefix, but skip if owner is empty
123        if feature.owner.is_empty() {
124            entries.push(format!("{} @Unknown", path_str));
125        } else {
126            let owner = format_owner(&feature.owner);
127            entries.push(format!("{} {}", path_str, owner));
128        }
129
130        // Recursively collect from nested features
131        collect_entries(&feature.features, base_path, project_dir, entries);
132    }
133}
134
135/// Format owner with @ prefix if not already present
136fn format_owner(owner: &str) -> String {
137    if owner.starts_with('@') {
138        owner.to_string()
139    } else {
140        format!("@{}", owner)
141    }
142}
143
144/// Write the CODEOWNERS file with preserved content and generated section
145fn write_codeowners_file(
146    codeowners_path: &Path,
147    before_section: &str,
148    entries: &[String],
149    after_section: &str,
150) -> Result<()> {
151    let mut file = fs::File::create(codeowners_path)?;
152
153    // Write content before the generated section
154    if !before_section.is_empty() {
155        writeln!(file, "{}", before_section)?;
156        if !before_section.ends_with('\n') {
157            writeln!(file)?;
158        }
159    }
160
161    // Write the generated section
162    writeln!(file, "{}", SECTION_START)?;
163    for entry in entries {
164        writeln!(file, "{}", entry)?;
165    }
166    writeln!(file, "{}", SECTION_END)?;
167
168    // Write content after the generated section
169    if !after_section.is_empty() {
170        if !after_section.starts_with('\n') {
171            writeln!(file)?;
172        }
173        write!(file, "{}", after_section)?;
174        if !after_section.ends_with('\n') {
175            writeln!(file)?;
176        }
177    }
178
179    Ok(())
180}