features_cli/
codeowners.rs1use 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
11pub 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 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 let mut entries = Vec::new();
45 collect_entries(features, base_path, project_dir, &mut entries);
46
47 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
57fn 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 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 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 Ok((lines.join("\n"), String::new()))
85 }
86 }
87}
88
89fn 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 let feature_path = std::path::PathBuf::from(&feature.path);
99 let full_path = base_path.join(&feature_path);
100
101 let codeowners_path = if let Some(proj_dir) = project_dir {
103 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 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 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 collect_entries(&feature.features, base_path, project_dir, entries);
132 }
133}
134
135fn format_owner(owner: &str) -> String {
137 if owner.starts_with('@') {
138 owner.to_string()
139 } else {
140 format!("@{}", owner)
141 }
142}
143
144fn 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 if !before_section.is_empty() {
155 writeln!(file, "{}", before_section)?;
156 if !before_section.ends_with('\n') {
157 writeln!(file)?;
158 }
159 }
160
161 writeln!(file, "{}", SECTION_START)?;
163 for entry in entries {
164 writeln!(file, "{}", entry)?;
165 }
166 writeln!(file, "{}", SECTION_END)?;
167
168 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}