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 metadata_key = after_dashes[..metadata_key_end].to_string();
148
149 let properties_start = feature_start + 2 + metadata_key_end; let properties_content = if properties_start < comment_content.len() {
152 comment_content[properties_start..].trim_start()
153 } else {
154 ""
155 };
156
157 let properties = parse_properties(properties_content);
159
160 if !properties.is_empty() {
162 return Some((metadata_key, properties));
163 }
164 }
165 }
166
167 None
168}
169
170fn scan_file(file_path: &Path) -> Result<Vec<FeatureMetadataComment>> {
172 let mut results = Vec::new();
173
174 let extension = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
176
177 let patterns = get_comment_patterns(extension);
178
179 let content = fs::read_to_string(file_path)?;
181
182 for (line_number, line) in content.lines().enumerate() {
184 if let Some((metadata_key, properties)) = check_line_for_feature_metadata(line, &patterns) {
185 results.push(FeatureMetadataComment {
186 file_path: file_path.to_string_lossy().to_string(),
187 line_number: line_number + 1, metadata_key,
189 properties,
190 });
191 }
192 }
193
194 Ok(results)
195}
196
197fn infer_feature_name_from_path(file_path: &Path, base_path: &Path) -> Option<String> {
205 let relative_path = file_path.strip_prefix(base_path).ok()?;
207
208 let components: Vec<_> = relative_path.components().collect();
210
211 for (i, component) in components.iter().enumerate() {
212 if let Some(os_str) = component.as_os_str().to_str()
213 && os_str == "features"
214 && let Some(next_component) = components.get(i + 1)
215 && let Some(feature_name) = next_component.as_os_str().to_str()
216 {
217 return Some(feature_name.to_string());
218 }
219 }
220
221 None
222}
223
224pub fn scan_directory_for_feature_metadata(dir_path: &Path) -> Result<FeatureMetadataMap> {
231 let mut feature_metadata = FeatureMetadataMap::new();
232
233 let skip_dirs = [
235 "node_modules",
236 "target",
237 "dist",
238 "build",
239 ".git",
240 ".svn",
241 ".hg",
242 "vendor",
243 "__pycache__",
244 ".next",
245 ".nuxt",
246 "coverage",
247 ];
248
249 for entry in WalkDir::new(dir_path)
250 .into_iter()
251 .filter_entry(|e| {
252 if e.file_type().is_dir() {
253 let dir_name = e.file_name().to_string_lossy();
254 !skip_dirs.contains(&dir_name.as_ref())
255 } else {
256 true
257 }
258 })
259 .filter_map(|e| e.ok())
260 {
261 if entry.file_type().is_file()
262 && let Ok(comments) = scan_file(entry.path())
263 {
264 for comment in comments {
265 let feature_name = comment
267 .properties
268 .get("feature")
269 .cloned()
270 .or_else(|| infer_feature_name_from_path(entry.path(), dir_path));
271
272 if let Some(feature_name) = feature_name {
273 feature_metadata
274 .entry(feature_name)
275 .or_default()
276 .entry(comment.metadata_key)
277 .or_default()
278 .push(comment.properties);
279 }
280 }
281 }
282 }
283
284 Ok(feature_metadata)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_extract_comment_content_single_line() {
293 let patterns = vec![CommentPattern::LineComment("//")];
294 let line = "// This is a comment";
295 assert_eq!(
296 extract_comment_content(line, &patterns),
297 Some("This is a comment".to_string())
298 );
299 }
300
301 #[test]
302 fn test_extract_comment_content_block() {
303 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
304 let line = "/* This is a block comment */";
305 assert_eq!(
306 extract_comment_content(line, &patterns),
307 Some("This is a block comment".to_string())
308 );
309 }
310
311 #[test]
312 fn test_extract_comment_content_hash() {
313 let patterns = vec![CommentPattern::LineComment("#")];
314 let line = "# This is a Python comment";
315 assert_eq!(
316 extract_comment_content(line, &patterns),
317 Some("This is a Python comment".to_string())
318 );
319 }
320
321 #[test]
322 fn test_parse_properties() {
323 let content =
324 "feature:feature-1, type: experiment, owner: #owner, introduced_on: 2025-12-31";
325 let props = parse_properties(content);
326
327 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
328 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
329 assert_eq!(props.get("owner"), Some(&"#owner".to_string()));
330 assert_eq!(props.get("introduced_on"), Some(&"2025-12-31".to_string()));
331 }
332
333 #[test]
334 fn test_check_line_for_feature_metadata_js_style() {
335 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
336 let line = "/** --feature-flag feature:feature-1, type: experiment, owner: #owner */";
337
338 let result = check_line_for_feature_metadata(line, &patterns);
339 assert!(result.is_some());
340
341 let (metadata_key, props) = result.unwrap();
342 assert_eq!(metadata_key, "feature-flag");
343 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
344 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
345 }
346 #[test]
347 fn test_check_line_for_feature_metadata_rust_style() {
348 let patterns = vec![CommentPattern::LineComment("//")];
349 let line = "// --feature-flag feature:my-feature, enabled: true";
350
351 let result = check_line_for_feature_metadata(line, &patterns);
352 assert!(result.is_some());
353
354 let (metadata_key, props) = result.unwrap();
355 assert_eq!(metadata_key, "feature-flag");
356 assert_eq!(props.get("feature"), Some(&"my-feature".to_string()));
357 assert_eq!(props.get("enabled"), Some(&"true".to_string()));
358 }
359
360 #[test]
361 fn test_check_line_for_feature_metadata_python_style() {
362 let patterns = vec![CommentPattern::LineComment("#")];
363 let line = "# --feature-flag feature:analytics, team: data-team";
364
365 let result = check_line_for_feature_metadata(line, &patterns);
366 assert!(result.is_some());
367
368 let (metadata_key, props) = result.unwrap();
369 assert_eq!(metadata_key, "feature-flag");
370 assert_eq!(props.get("feature"), Some(&"analytics".to_string()));
371 assert_eq!(props.get("team"), Some(&"data-team".to_string()));
372 }
373
374 #[test]
375 fn test_no_feature_metadata_in_regular_comment() {
376 let patterns = vec![CommentPattern::LineComment("//")];
377 let line = "// This is just a regular comment";
378
379 assert!(check_line_for_feature_metadata(line, &patterns).is_none());
380 }
381
382 #[test]
383 fn test_different_metadata_keys() {
384 let patterns = vec![CommentPattern::LineComment("//")];
385
386 let line1 = "// --feature-experiment feature:test-feature, status: active";
387 let result1 = check_line_for_feature_metadata(line1, &patterns);
388 assert!(result1.is_some());
389 let (metadata_key1, _) = result1.unwrap();
390 assert_eq!(metadata_key1, "feature-experiment");
391
392 let line2 = "// --feature-toggle feature:another-feature, enabled: true";
393 let result2 = check_line_for_feature_metadata(line2, &patterns);
394 assert!(result2.is_some());
395 let (metadata_key2, _) = result2.unwrap();
396 assert_eq!(metadata_key2, "feature-toggle");
397 }
398}