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