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 let mut entries: Vec<_> = WalkDir::new(dir_path)
263 .into_iter()
264 .filter_entry(|e| {
265 if e.file_type().is_dir() {
266 let dir_name = e.file_name().to_string_lossy();
267 !skip_dirs.contains(&dir_name.as_ref())
268 } else {
269 true
270 }
271 })
272 .filter_map(|e| e.ok())
273 .collect();
274
275 entries.sort_by(|a, b| a.path().cmp(b.path()));
277
278 for entry in entries {
279 if entry.file_type().is_file()
280 && let Ok(comments) = scan_file(entry.path())
281 {
282 for comment in comments {
283 let feature_path = comment
285 .properties
286 .get("feature")
287 .cloned()
288 .or_else(|| infer_feature_path_from_file(entry.path(), dir_path));
289
290 if let Some(feature_path) = feature_path {
291 feature_metadata
292 .entry(feature_path)
293 .or_default()
294 .entry(comment.metadata_key)
295 .or_default()
296 .push(comment.properties);
297 }
298 }
299 }
300 }
301
302 Ok(feature_metadata)
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_extract_comment_content_single_line() {
311 let patterns = vec![CommentPattern::LineComment("//")];
312 let line = "// This is a comment";
313 assert_eq!(
314 extract_comment_content(line, &patterns),
315 Some("This is a comment".to_string())
316 );
317 }
318
319 #[test]
320 fn test_extract_comment_content_block() {
321 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
322 let line = "/* This is a block comment */";
323 assert_eq!(
324 extract_comment_content(line, &patterns),
325 Some("This is a block comment".to_string())
326 );
327 }
328
329 #[test]
330 fn test_extract_comment_content_hash() {
331 let patterns = vec![CommentPattern::LineComment("#")];
332 let line = "# This is a Python comment";
333 assert_eq!(
334 extract_comment_content(line, &patterns),
335 Some("This is a Python comment".to_string())
336 );
337 }
338
339 #[test]
340 fn test_parse_properties() {
341 let content =
342 "feature:feature-1, type: experiment, owner: #owner, introduced_on: 2025-12-31";
343 let props = parse_properties(content);
344
345 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
346 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
347 assert_eq!(props.get("owner"), Some(&"#owner".to_string()));
348 assert_eq!(props.get("introduced_on"), Some(&"2025-12-31".to_string()));
349 }
350
351 #[test]
352 fn test_check_line_for_feature_metadata_js_style() {
353 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
354 let line = "/** --feature-flag feature:feature-1, type: experiment, owner: #owner */";
355
356 let result = check_line_for_feature_metadata(line, &patterns);
357 assert!(result.is_some());
358
359 let (metadata_key, props) = result.unwrap();
360 assert_eq!(metadata_key, "flag");
361 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
362 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
363 }
364 #[test]
365 fn test_check_line_for_feature_metadata_rust_style() {
366 let patterns = vec![CommentPattern::LineComment("//")];
367 let line = "// --feature-flag feature:my-feature, enabled: true";
368
369 let result = check_line_for_feature_metadata(line, &patterns);
370 assert!(result.is_some());
371
372 let (metadata_key, props) = result.unwrap();
373 assert_eq!(metadata_key, "flag");
374 assert_eq!(props.get("feature"), Some(&"my-feature".to_string()));
375 assert_eq!(props.get("enabled"), Some(&"true".to_string()));
376 }
377
378 #[test]
379 fn test_check_line_for_feature_metadata_python_style() {
380 let patterns = vec![CommentPattern::LineComment("#")];
381 let line = "# --feature-flag feature:analytics, team: data-team";
382
383 let result = check_line_for_feature_metadata(line, &patterns);
384 assert!(result.is_some());
385
386 let (metadata_key, props) = result.unwrap();
387 assert_eq!(metadata_key, "flag");
388 assert_eq!(props.get("feature"), Some(&"analytics".to_string()));
389 assert_eq!(props.get("team"), Some(&"data-team".to_string()));
390 }
391
392 #[test]
393 fn test_no_feature_metadata_in_regular_comment() {
394 let patterns = vec![CommentPattern::LineComment("//")];
395 let line = "// This is just a regular comment";
396
397 assert!(check_line_for_feature_metadata(line, &patterns).is_none());
398 }
399
400 #[test]
401 fn test_different_metadata_keys() {
402 let patterns = vec![CommentPattern::LineComment("//")];
403
404 let line1 = "// --feature-experiment feature:test-feature, status: active";
405 let result1 = check_line_for_feature_metadata(line1, &patterns);
406 assert!(result1.is_some());
407 let (metadata_key1, _) = result1.unwrap();
408 assert_eq!(metadata_key1, "experiment");
409
410 let line2 = "// --feature-toggle feature:another-feature, enabled: true";
411 let result2 = check_line_for_feature_metadata(line2, &patterns);
412 assert!(result2.is_some());
413 let (metadata_key2, _) = result2.unwrap();
414 assert_eq!(metadata_key2, "toggle");
415 }
416}