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 collect_entries_with_parent(features, base_path, project_dir, entries, None);
97}
98
99fn 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 let should_skip = parent_owner.is_some_and(|parent| feature.owner == parent);
110
111 if !should_skip {
112 let feature_path = std::path::PathBuf::from(&feature.path);
114 let full_path = base_path.join(&feature_path);
115
116 let codeowners_path = if let Some(proj_dir) = project_dir {
118 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 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 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 collect_entries_with_parent(
148 &feature.features,
149 base_path,
150 project_dir,
151 entries,
152 Some(&feature.owner),
153 );
154 }
155}
156
157fn format_owner(owner: &str) -> String {
159 if owner.starts_with('@') {
160 owner.to_string()
161 } else {
162 format!("@{}", owner)
163 }
164}
165
166fn 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 if !before_section.is_empty() {
177 writeln!(file, "{}", before_section)?;
178 if !before_section.ends_with('\n') {
179 writeln!(file)?;
180 }
181 }
182
183 writeln!(file, "{}", SECTION_START)?;
185 for entry in entries {
186 writeln!(file, "{}", entry)?;
187 }
188 writeln!(file, "{}", SECTION_END)?;
189
190 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}