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 indexmap::IndexMap;
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12use walkdir::WalkDir;
13
14/// Represents a single metadata entry's properties (key-value pairs)
15/// Uses IndexMap to preserve insertion order from comments
16type MetadataProperties = IndexMap<String, String>;
17
18/// Represents a list of metadata entries for a specific metadata key
19type MetadataEntries = Vec<MetadataProperties>;
20
21/// Maps metadata keys (e.g., "flag") to their entries
22type MetadataByKey = HashMap<String, MetadataEntries>;
23
24/// Maps feature names to their metadata, organized by metadata key
25pub 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
37/// Detects comment start patterns for various languages based on file extension
38fn get_comment_patterns(extension: &str) -> Vec<CommentPattern> {
39    match extension {
40        // C-style comments (C, C++, Java, JavaScript, TypeScript, Rust, etc.)
41        "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        // Python, Bash, Shell, Ruby, Perl, YAML, etc.
47        "py" | "sh" | "bash" | "rb" | "pl" | "yml" | "yaml" | "toml" => {
48            vec![CommentPattern::LineComment("#")]
49        }
50        // HTML, XML
51        "html" | "xml" | "svg" => vec![CommentPattern::BlockComment("<!--", "-->")],
52        // CSS, SCSS, Less
53        "css" | "scss" | "less" => vec![
54            CommentPattern::LineComment("//"),
55            CommentPattern::BlockComment("/*", "*/"),
56        ],
57        // Lua
58        "lua" => vec![
59            CommentPattern::LineComment("--"),
60            CommentPattern::BlockComment("--[[", "]]"),
61        ],
62        // SQL
63        "sql" => vec![
64            CommentPattern::LineComment("--"),
65            CommentPattern::BlockComment("/*", "*/"),
66        ],
67        // Default: try common patterns
68        _ => 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
82/// Extracts the content from a comment, handling different comment styles
83fn 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                    // Remove ending marker if present on same line
96                    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
108/// Parses properties from a feature flag comment
109/// Format: "key: value, key2: value2, ..."
110/// Preserves the order of properties as they appear in the comment
111fn parse_properties(content: &str) -> MetadataProperties {
112    let mut properties = IndexMap::new();
113
114    // Split by comma and parse key:value pairs
115    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
130/// Checks if a line contains a feature metadata comment
131/// Returns the metadata key (e.g., "flag" from "--feature-flag") and the properties
132fn 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        // Check if the comment contains "--feature-" pattern
138        if let Some(feature_start) = comment_content.find("--feature-") {
139            // Extract the metadata key (e.g., "--feature-flag" -> "flag")
140            let after_dashes = &comment_content[feature_start + 2..]; // Skip "--"
141
142            // Find where the metadata key ends (at space, comma, or end of string)
143            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            // Strip "feature-" prefix to get just the key (e.g., "feature-flag" -> "flag")
150            let metadata_key = full_key
151                .strip_prefix("feature-")
152                .unwrap_or(&full_key)
153                .to_string();
154
155            // Extract everything after the metadata key for property parsing
156            let properties_start = feature_start + 2 + metadata_key_end; // +2 for "--"
157            let properties_content = if properties_start < comment_content.len() {
158                comment_content[properties_start..].trim_start()
159            } else {
160                ""
161            };
162
163            // Parse properties from the content after the metadata key
164            let properties = parse_properties(properties_content);
165
166            // Only return if we actually found some properties or patterns
167            if !properties.is_empty() {
168                return Some((metadata_key, properties));
169            }
170        }
171    }
172
173    None
174}
175
176/// Scans a single file for feature metadata comments
177fn scan_file(file_path: &Path) -> Result<Vec<FeatureMetadataComment>> {
178    let mut results = Vec::new();
179
180    // Get file extension
181    let extension = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
182
183    let patterns = get_comment_patterns(extension);
184
185    // Read file content
186    let content = fs::read_to_string(file_path)?;
187
188    // Check each line
189    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, // Line numbers are 1-based
194                metadata_key,
195                properties,
196            });
197        }
198    }
199
200    Ok(results)
201}
202
203/// Attempts to infer the feature name from a file path by looking for a 'features' directory
204/// in the path hierarchy and extracting the immediate subdirectory name
205///
206/// For example:
207/// - `src/features/user-auth/component.tsx` -> Some("user-auth")
208/// - `libs/features/api-v2/utils.ts` -> Some("api-v2")
209/// - `src/components/Button.tsx` -> None
210fn infer_feature_name_from_path(file_path: &Path, base_path: &Path) -> Option<String> {
211    // Get the relative path from base_path
212    let relative_path = file_path.strip_prefix(base_path).ok()?;
213
214    // Look for 'features' directory in the path components
215    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
230/// Scans a directory recursively for feature metadata comments
231///
232/// Returns a nested map:
233/// - Outer key: feature name (from "feature:feature-1")
234/// - Inner key: metadata key (e.g., "flag" from "--feature-flag", "experiment" from "--feature-experiment")
235/// - Value: vector of property maps
236pub fn scan_directory_for_feature_metadata(dir_path: &Path) -> Result<FeatureMetadataMap> {
237    let mut feature_metadata = FeatureMetadataMap::new();
238
239    // Skip common directories that shouldn't be scanned
240    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                // Get the feature name from the properties, or infer from path
272                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}