1use anyhow::Result;
8use indexmap::IndexMap;
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12use walkdir::WalkDir;
13
14type MetadataProperties = IndexMap<String, String>;
17
18type MetadataEntries = Vec<MetadataProperties>;
20
21type MetadataByKey = HashMap<String, MetadataEntries>;
23
24pub type FeatureMetadataMap = HashMap<String, MetadataByKey>;
26
27#[derive(Debug, Clone)]
28pub struct FeatureMetadataComment {
29 #[allow(dead_code)]
30 pub file_path: String,
31 #[allow(dead_code)]
32 pub line_number: usize,
33 pub metadata_key: String,
34 pub properties: MetadataProperties,
35}
36
37fn get_comment_patterns(extension: &str) -> Vec<CommentPattern> {
39 match extension {
40 "rs" | "c" | "cpp" | "cc" | "cxx" | "h" | "hpp" | "java" | "js" | "jsx" | "ts" | "tsx"
42 | "go" | "cs" | "swift" | "kt" | "scala" => vec![
43 CommentPattern::LineComment("//"),
44 CommentPattern::BlockComment("/*", "*/"),
45 ],
46 "py" | "sh" | "bash" | "rb" | "pl" | "yml" | "yaml" | "toml" => {
48 vec![CommentPattern::LineComment("#")]
49 }
50 "html" | "xml" | "svg" => vec![CommentPattern::BlockComment("<!--", "-->")],
52 "css" | "scss" | "less" => vec![
54 CommentPattern::LineComment("//"),
55 CommentPattern::BlockComment("/*", "*/"),
56 ],
57 "lua" => vec![
59 CommentPattern::LineComment("--"),
60 CommentPattern::BlockComment("--[[", "]]"),
61 ],
62 "sql" => vec![
64 CommentPattern::LineComment("--"),
65 CommentPattern::BlockComment("/*", "*/"),
66 ],
67 _ => vec![
69 CommentPattern::LineComment("//"),
70 CommentPattern::LineComment("#"),
71 CommentPattern::BlockComment("/*", "*/"),
72 ],
73 }
74}
75
76#[derive(Debug, Clone)]
77enum CommentPattern {
78 LineComment(&'static str),
79 BlockComment(&'static str, &'static str),
80}
81
82fn extract_comment_content(line: &str, patterns: &[CommentPattern]) -> Option<String> {
84 let trimmed = line.trim();
85
86 for pattern in patterns {
87 match pattern {
88 CommentPattern::LineComment(prefix) => {
89 if let Some(content) = trimmed.strip_prefix(prefix) {
90 return Some(content.trim().to_string());
91 }
92 }
93 CommentPattern::BlockComment(start, end) => {
94 if let Some(mut content) = trimmed.strip_prefix(start) {
95 if let Some(stripped) = content.strip_suffix(end) {
97 content = stripped;
98 }
99 return Some(content.trim().to_string());
100 }
101 }
102 }
103 }
104
105 None
106}
107
108fn parse_properties(content: &str) -> MetadataProperties {
112 let mut properties = IndexMap::new();
113
114 for part in content.split(',') {
116 let part = part.trim();
117 if let Some(colon_pos) = part.find(':') {
118 let key = part[..colon_pos].trim();
119 let value = part[colon_pos + 1..].trim();
120
121 if !key.is_empty() {
122 properties.insert(key.to_string(), value.to_string());
123 }
124 }
125 }
126
127 properties
128}
129
130fn check_line_for_feature_metadata(
133 line: &str,
134 patterns: &[CommentPattern],
135) -> Option<(String, MetadataProperties)> {
136 if let Some(comment_content) = extract_comment_content(line, patterns) {
137 if let Some(feature_start) = comment_content.find("--feature-") {
139 let after_dashes = &comment_content[feature_start + 2..]; let metadata_key_end = after_dashes
144 .find(|c: char| c.is_whitespace() || c == ',')
145 .unwrap_or(after_dashes.len());
146
147 let full_key = after_dashes[..metadata_key_end].to_string();
148
149 let metadata_key = full_key
151 .strip_prefix("feature-")
152 .unwrap_or(&full_key)
153 .to_string();
154
155 let properties_start = feature_start + 2 + metadata_key_end; let properties_content = if properties_start < comment_content.len() {
158 comment_content[properties_start..].trim_start()
159 } else {
160 ""
161 };
162
163 let properties = parse_properties(properties_content);
165
166 if !properties.is_empty() {
168 return Some((metadata_key, properties));
169 }
170 }
171 }
172
173 None
174}
175
176fn scan_file(file_path: &Path) -> Result<Vec<FeatureMetadataComment>> {
178 let mut results = Vec::new();
179
180 let extension = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
182
183 let patterns = get_comment_patterns(extension);
184
185 let content = fs::read_to_string(file_path)?;
187
188 for (line_number, line) in content.lines().enumerate() {
190 if let Some((metadata_key, properties)) = check_line_for_feature_metadata(line, &patterns) {
191 results.push(FeatureMetadataComment {
192 file_path: file_path.to_string_lossy().to_string(),
193 line_number: line_number + 1, metadata_key,
195 properties,
196 });
197 }
198 }
199
200 Ok(results)
201}
202
203fn infer_feature_path_from_file(file_path: &Path, base_path: &Path) -> Option<String> {
211 let relative_path = file_path.strip_prefix(base_path).ok()?;
213
214 let components: Vec<_> = relative_path.components().collect();
216
217 for (i, component) in components.iter().enumerate() {
218 if let Some(os_str) = component.as_os_str().to_str()
219 && os_str == "features"
220 && let Some(_next_component) = components.get(i + 1)
221 {
222 let mut feature_path = std::path::PathBuf::new();
224 for j in 0..=i + 1 {
225 if let Some(comp) = components.get(j) {
226 feature_path.push(comp);
227 }
228 }
229 return Some(feature_path.to_string_lossy().to_string());
230 }
231 }
232
233 None
234}
235
236pub fn scan_directory_for_feature_metadata(dir_path: &Path) -> Result<FeatureMetadataMap> {
243 let mut feature_metadata = FeatureMetadataMap::new();
244
245 let skip_dirs = [
247 "node_modules",
248 "target",
249 "dist",
250 "build",
251 ".git",
252 ".svn",
253 ".hg",
254 "vendor",
255 "__pycache__",
256 ".next",
257 ".nuxt",
258 "coverage",
259 ];
260
261 for entry in WalkDir::new(dir_path)
262 .into_iter()
263 .filter_entry(|e| {
264 if e.file_type().is_dir() {
265 let dir_name = e.file_name().to_string_lossy();
266 !skip_dirs.contains(&dir_name.as_ref())
267 } else {
268 true
269 }
270 })
271 .filter_map(|e| e.ok())
272 {
273 if entry.file_type().is_file()
274 && let Ok(comments) = scan_file(entry.path())
275 {
276 for comment in comments {
277 let feature_path = comment
279 .properties
280 .get("feature")
281 .cloned()
282 .or_else(|| infer_feature_path_from_file(entry.path(), dir_path));
283
284 if let Some(feature_path) = feature_path {
285 feature_metadata
286 .entry(feature_path)
287 .or_default()
288 .entry(comment.metadata_key)
289 .or_default()
290 .push(comment.properties);
291 }
292 }
293 }
294 }
295
296 Ok(feature_metadata)
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn test_extract_comment_content_single_line() {
305 let patterns = vec![CommentPattern::LineComment("//")];
306 let line = "// This is a comment";
307 assert_eq!(
308 extract_comment_content(line, &patterns),
309 Some("This is a comment".to_string())
310 );
311 }
312
313 #[test]
314 fn test_extract_comment_content_block() {
315 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
316 let line = "/* This is a block comment */";
317 assert_eq!(
318 extract_comment_content(line, &patterns),
319 Some("This is a block comment".to_string())
320 );
321 }
322
323 #[test]
324 fn test_extract_comment_content_hash() {
325 let patterns = vec![CommentPattern::LineComment("#")];
326 let line = "# This is a Python comment";
327 assert_eq!(
328 extract_comment_content(line, &patterns),
329 Some("This is a Python comment".to_string())
330 );
331 }
332
333 #[test]
334 fn test_parse_properties() {
335 let content =
336 "feature:feature-1, type: experiment, owner: #owner, introduced_on: 2025-12-31";
337 let props = parse_properties(content);
338
339 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
340 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
341 assert_eq!(props.get("owner"), Some(&"#owner".to_string()));
342 assert_eq!(props.get("introduced_on"), Some(&"2025-12-31".to_string()));
343 }
344
345 #[test]
346 fn test_check_line_for_feature_metadata_js_style() {
347 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
348 let line = "/** --feature-flag feature:feature-1, type: experiment, owner: #owner */";
349
350 let result = check_line_for_feature_metadata(line, &patterns);
351 assert!(result.is_some());
352
353 let (metadata_key, props) = result.unwrap();
354 assert_eq!(metadata_key, "flag");
355 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
356 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
357 }
358 #[test]
359 fn test_check_line_for_feature_metadata_rust_style() {
360 let patterns = vec![CommentPattern::LineComment("//")];
361 let line = "// --feature-flag feature:my-feature, enabled: true";
362
363 let result = check_line_for_feature_metadata(line, &patterns);
364 assert!(result.is_some());
365
366 let (metadata_key, props) = result.unwrap();
367 assert_eq!(metadata_key, "flag");
368 assert_eq!(props.get("feature"), Some(&"my-feature".to_string()));
369 assert_eq!(props.get("enabled"), Some(&"true".to_string()));
370 }
371
372 #[test]
373 fn test_check_line_for_feature_metadata_python_style() {
374 let patterns = vec![CommentPattern::LineComment("#")];
375 let line = "# --feature-flag feature:analytics, team: data-team";
376
377 let result = check_line_for_feature_metadata(line, &patterns);
378 assert!(result.is_some());
379
380 let (metadata_key, props) = result.unwrap();
381 assert_eq!(metadata_key, "flag");
382 assert_eq!(props.get("feature"), Some(&"analytics".to_string()));
383 assert_eq!(props.get("team"), Some(&"data-team".to_string()));
384 }
385
386 #[test]
387 fn test_no_feature_metadata_in_regular_comment() {
388 let patterns = vec![CommentPattern::LineComment("//")];
389 let line = "// This is just a regular comment";
390
391 assert!(check_line_for_feature_metadata(line, &patterns).is_none());
392 }
393
394 #[test]
395 fn test_different_metadata_keys() {
396 let patterns = vec![CommentPattern::LineComment("//")];
397
398 let line1 = "// --feature-experiment feature:test-feature, status: active";
399 let result1 = check_line_for_feature_metadata(line1, &patterns);
400 assert!(result1.is_some());
401 let (metadata_key1, _) = result1.unwrap();
402 assert_eq!(metadata_key1, "experiment");
403
404 let line2 = "// --feature-toggle feature:another-feature, enabled: true";
405 let result2 = check_line_for_feature_metadata(line2, &patterns);
406 assert!(result2.is_some());
407 let (metadata_key2, _) = result2.unwrap();
408 assert_eq!(metadata_key2, "toggle");
409 }
410}