features_cli/
feature_metadata_detector.rs

1//! Module for detecting feature metadata in source code comments
2//!
3//! This module scans source files for special comments that start with "--feature-"
4//! and contain feature metadata. It supports multiple programming languages
5//! and their respective comment styles.
6
7use anyhow::Result;
8use std::collections::HashMap;
9use std::fs;
10use std::path::Path;
11use walkdir::WalkDir;
12
13/// Represents a single metadata entry's properties (key-value pairs)
14type MetadataProperties = HashMap<String, String>;
15
16/// Represents a list of metadata entries for a specific metadata key
17type MetadataEntries = Vec<MetadataProperties>;
18
19/// Maps metadata keys (e.g., "feature-flag") to their entries
20type MetadataByKey = HashMap<String, MetadataEntries>;
21
22/// Maps feature names to their metadata, organized by metadata key
23pub 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
35/// Detects comment start patterns for various languages based on file extension
36fn get_comment_patterns(extension: &str) -> Vec<CommentPattern> {
37    match extension {
38        // C-style comments (C, C++, Java, JavaScript, TypeScript, Rust, etc.)
39        "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        // Python, Bash, Shell, Ruby, Perl, YAML, etc.
45        "py" | "sh" | "bash" | "rb" | "pl" | "yml" | "yaml" | "toml" => {
46            vec![CommentPattern::LineComment("#")]
47        }
48        // HTML, XML
49        "html" | "xml" | "svg" => vec![CommentPattern::BlockComment("<!--", "-->")],
50        // CSS, SCSS, Less
51        "css" | "scss" | "less" => vec![
52            CommentPattern::LineComment("//"),
53            CommentPattern::BlockComment("/*", "*/"),
54        ],
55        // Lua
56        "lua" => vec![
57            CommentPattern::LineComment("--"),
58            CommentPattern::BlockComment("--[[", "]]"),
59        ],
60        // SQL
61        "sql" => vec![
62            CommentPattern::LineComment("--"),
63            CommentPattern::BlockComment("/*", "*/"),
64        ],
65        // Default: try common patterns
66        _ => 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
80/// Extracts the content from a comment, handling different comment styles
81fn 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                    // Remove ending marker if present on same line
94                    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
106/// Parses properties from a feature flag comment
107/// Format: "key: value, key2: value2, ..."
108fn parse_properties(content: &str) -> MetadataProperties {
109    let mut properties = MetadataProperties::new();
110
111    // Split by comma and parse key:value pairs
112    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
127/// Checks if a line contains a feature metadata comment
128/// Returns the metadata key (e.g., "feature-flag") and the properties
129fn 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        // Check if the comment contains "--feature-" pattern
135        if let Some(feature_start) = comment_content.find("--feature-") {
136            // Extract the metadata key (e.g., "--feature-flag" -> "feature-flag")
137            let after_dashes = &comment_content[feature_start + 2..]; // Skip "--"
138
139            // Find where the metadata key ends (at space, comma, or end of string)
140            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            // Extract everything after the metadata key for property parsing
147            let properties_start = feature_start + 2 + metadata_key_end; // +2 for "--"
148            let properties_content = if properties_start < comment_content.len() {
149                comment_content[properties_start..].trim_start()
150            } else {
151                ""
152            };
153
154            // Parse properties from the content after the metadata key
155            let properties = parse_properties(properties_content);
156
157            // Only return if we actually found some properties or patterns
158            if !properties.is_empty() {
159                return Some((metadata_key, properties));
160            }
161        }
162    }
163
164    None
165}
166
167/// Scans a single file for feature metadata comments
168fn scan_file(file_path: &Path) -> Result<Vec<FeatureMetadataComment>> {
169    let mut results = Vec::new();
170
171    // Get file extension
172    let extension = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
173
174    let patterns = get_comment_patterns(extension);
175
176    // Read file content
177    let content = fs::read_to_string(file_path)?;
178
179    // Check each line
180    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, // Line numbers are 1-based
185                metadata_key,
186                properties,
187            });
188        }
189    }
190
191    Ok(results)
192}
193
194/// Attempts to infer the feature name from a file path by looking for a 'features' directory
195/// in the path hierarchy and extracting the immediate subdirectory name
196///
197/// For example:
198/// - `src/features/user-auth/component.tsx` -> Some("user-auth")
199/// - `libs/features/api-v2/utils.ts` -> Some("api-v2")
200/// - `src/components/Button.tsx` -> None
201fn infer_feature_name_from_path(file_path: &Path, base_path: &Path) -> Option<String> {
202    // Get the relative path from base_path
203    let relative_path = file_path.strip_prefix(base_path).ok()?;
204
205    // Look for 'features' directory in the path components
206    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
221/// Scans a directory recursively for feature metadata comments
222///
223/// Returns a nested map:
224/// - Outer key: feature name (from "feature:feature-1")
225/// - Inner key: metadata key (from "--feature-flag", "--feature-experiment", etc.)
226/// - Value: vector of property maps
227pub fn scan_directory_for_feature_metadata(dir_path: &Path) -> Result<FeatureMetadataMap> {
228    let mut feature_metadata = FeatureMetadataMap::new();
229
230    // Skip common directories that shouldn't be scanned
231    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                // Get the feature name from the properties, or infer from path
263                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}