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_name_from_path(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 && let Some(feature_name) = next_component.as_os_str().to_str()
222 {
223 return Some(feature_name.to_string());
224 }
225 }
226
227 None
228}
229
230pub fn scan_directory_for_feature_metadata(dir_path: &Path) -> Result<FeatureMetadataMap> {
237 let mut feature_metadata = FeatureMetadataMap::new();
238
239 let skip_dirs = [
241 "node_modules",
242 "target",
243 "dist",
244 "build",
245 ".git",
246 ".svn",
247 ".hg",
248 "vendor",
249 "__pycache__",
250 ".next",
251 ".nuxt",
252 "coverage",
253 ];
254
255 for entry in WalkDir::new(dir_path)
256 .into_iter()
257 .filter_entry(|e| {
258 if e.file_type().is_dir() {
259 let dir_name = e.file_name().to_string_lossy();
260 !skip_dirs.contains(&dir_name.as_ref())
261 } else {
262 true
263 }
264 })
265 .filter_map(|e| e.ok())
266 {
267 if entry.file_type().is_file()
268 && let Ok(comments) = scan_file(entry.path())
269 {
270 for comment in comments {
271 let feature_name = comment
273 .properties
274 .get("feature")
275 .cloned()
276 .or_else(|| infer_feature_name_from_path(entry.path(), dir_path));
277
278 if let Some(feature_name) = feature_name {
279 feature_metadata
280 .entry(feature_name)
281 .or_default()
282 .entry(comment.metadata_key)
283 .or_default()
284 .push(comment.properties);
285 }
286 }
287 }
288 }
289
290 Ok(feature_metadata)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn test_extract_comment_content_single_line() {
299 let patterns = vec![CommentPattern::LineComment("//")];
300 let line = "// This is a comment";
301 assert_eq!(
302 extract_comment_content(line, &patterns),
303 Some("This is a comment".to_string())
304 );
305 }
306
307 #[test]
308 fn test_extract_comment_content_block() {
309 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
310 let line = "/* This is a block comment */";
311 assert_eq!(
312 extract_comment_content(line, &patterns),
313 Some("This is a block comment".to_string())
314 );
315 }
316
317 #[test]
318 fn test_extract_comment_content_hash() {
319 let patterns = vec![CommentPattern::LineComment("#")];
320 let line = "# This is a Python comment";
321 assert_eq!(
322 extract_comment_content(line, &patterns),
323 Some("This is a Python comment".to_string())
324 );
325 }
326
327 #[test]
328 fn test_parse_properties() {
329 let content =
330 "feature:feature-1, type: experiment, owner: #owner, introduced_on: 2025-12-31";
331 let props = parse_properties(content);
332
333 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
334 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
335 assert_eq!(props.get("owner"), Some(&"#owner".to_string()));
336 assert_eq!(props.get("introduced_on"), Some(&"2025-12-31".to_string()));
337 }
338
339 #[test]
340 fn test_check_line_for_feature_metadata_js_style() {
341 let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
342 let line = "/** --feature-flag feature:feature-1, type: experiment, owner: #owner */";
343
344 let result = check_line_for_feature_metadata(line, &patterns);
345 assert!(result.is_some());
346
347 let (metadata_key, props) = result.unwrap();
348 assert_eq!(metadata_key, "flag");
349 assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
350 assert_eq!(props.get("type"), Some(&"experiment".to_string()));
351 }
352 #[test]
353 fn test_check_line_for_feature_metadata_rust_style() {
354 let patterns = vec![CommentPattern::LineComment("//")];
355 let line = "// --feature-flag feature:my-feature, enabled: true";
356
357 let result = check_line_for_feature_metadata(line, &patterns);
358 assert!(result.is_some());
359
360 let (metadata_key, props) = result.unwrap();
361 assert_eq!(metadata_key, "flag");
362 assert_eq!(props.get("feature"), Some(&"my-feature".to_string()));
363 assert_eq!(props.get("enabled"), Some(&"true".to_string()));
364 }
365
366 #[test]
367 fn test_check_line_for_feature_metadata_python_style() {
368 let patterns = vec![CommentPattern::LineComment("#")];
369 let line = "# --feature-flag feature:analytics, team: data-team";
370
371 let result = check_line_for_feature_metadata(line, &patterns);
372 assert!(result.is_some());
373
374 let (metadata_key, props) = result.unwrap();
375 assert_eq!(metadata_key, "flag");
376 assert_eq!(props.get("feature"), Some(&"analytics".to_string()));
377 assert_eq!(props.get("team"), Some(&"data-team".to_string()));
378 }
379
380 #[test]
381 fn test_no_feature_metadata_in_regular_comment() {
382 let patterns = vec![CommentPattern::LineComment("//")];
383 let line = "// This is just a regular comment";
384
385 assert!(check_line_for_feature_metadata(line, &patterns).is_none());
386 }
387
388 #[test]
389 fn test_different_metadata_keys() {
390 let patterns = vec![CommentPattern::LineComment("//")];
391
392 let line1 = "// --feature-experiment feature:test-feature, status: active";
393 let result1 = check_line_for_feature_metadata(line1, &patterns);
394 assert!(result1.is_some());
395 let (metadata_key1, _) = result1.unwrap();
396 assert_eq!(metadata_key1, "experiment");
397
398 let line2 = "// --feature-toggle feature:another-feature, enabled: true";
399 let result2 = check_line_for_feature_metadata(line2, &patterns);
400 assert!(result2.is_some());
401 let (metadata_key2, _) = result2.unwrap();
402 assert_eq!(metadata_key2, "toggle");
403 }
404}