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., "feature-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., "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" -> "feature-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 metadata_key = after_dashes[..metadata_key_end].to_string();
148
149            // Extract everything after the metadata key for property parsing
150            let properties_start = feature_start + 2 + metadata_key_end; // +2 for "--"
151            let properties_content = if properties_start < comment_content.len() {
152                comment_content[properties_start..].trim_start()
153            } else {
154                ""
155            };
156
157            // Parse properties from the content after the metadata key
158            let properties = parse_properties(properties_content);
159
160            // Only return if we actually found some properties or patterns
161            if !properties.is_empty() {
162                return Some((metadata_key, properties));
163            }
164        }
165    }
166
167    None
168}
169
170/// Scans a single file for feature metadata comments
171fn scan_file(file_path: &Path) -> Result<Vec<FeatureMetadataComment>> {
172    let mut results = Vec::new();
173
174    // Get file extension
175    let extension = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
176
177    let patterns = get_comment_patterns(extension);
178
179    // Read file content
180    let content = fs::read_to_string(file_path)?;
181
182    // Check each line
183    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, // Line numbers are 1-based
188                metadata_key,
189                properties,
190            });
191        }
192    }
193
194    Ok(results)
195}
196
197/// Attempts to infer the feature name from a file path by looking for a 'features' directory
198/// in the path hierarchy and extracting the immediate subdirectory name
199///
200/// For example:
201/// - `src/features/user-auth/component.tsx` -> Some("user-auth")
202/// - `libs/features/api-v2/utils.ts` -> Some("api-v2")
203/// - `src/components/Button.tsx` -> None
204fn infer_feature_name_from_path(file_path: &Path, base_path: &Path) -> Option<String> {
205    // Get the relative path from base_path
206    let relative_path = file_path.strip_prefix(base_path).ok()?;
207
208    // Look for 'features' directory in the path components
209    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
224/// Scans a directory recursively for feature metadata comments
225///
226/// Returns a nested map:
227/// - Outer key: feature name (from "feature:feature-1")
228/// - Inner key: metadata key (from "--feature-flag", "--feature-experiment", etc.)
229/// - Value: vector of property maps
230pub fn scan_directory_for_feature_metadata(dir_path: &Path) -> Result<FeatureMetadataMap> {
231    let mut feature_metadata = FeatureMetadataMap::new();
232
233    // Skip common directories that shouldn't be scanned
234    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                // Get the feature name from the properties, or infer from path
266                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}