Skip to main content

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 paths 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 path from a file path by looking for a 'features' directory
204/// in the path hierarchy and extracting the path from base to the feature directory
205///
206/// For example (with base_path as project root):
207/// - `src/features/user-auth/component.tsx` -> Some("src/features/user-auth")
208/// - `libs/features/api-v2/utils.ts` -> Some("libs/features/api-v2")
209/// - `src/components/Button.tsx` -> None
210fn infer_feature_path_from_file(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        {
222            // Build the path up to and including the feature directory (i+1)
223            let mut feature_path = std::path::PathBuf::new();
224            for j in 0..=i + 1 {
225                if let Some(comp) = components.get(j) {
226                    feature_path.push(comp);
227                }
228            }
229            return Some(feature_path.to_string_lossy().to_string());
230        }
231    }
232
233    None
234}
235
236/// Scans a directory recursively for feature metadata comments
237///
238/// Returns a nested map:
239/// - Outer key: feature name (from "feature:feature-1")
240/// - Inner key: metadata key (e.g., "flag" from "--feature-flag", "experiment" from "--feature-experiment")
241/// - Value: vector of property maps
242pub fn scan_directory_for_feature_metadata(dir_path: &Path) -> Result<FeatureMetadataMap> {
243    let mut feature_metadata = FeatureMetadataMap::new();
244
245    // Skip common directories that shouldn't be scanned
246    let skip_dirs = [
247        "node_modules",
248        "target",
249        "dist",
250        "build",
251        ".git",
252        ".svn",
253        ".hg",
254        "vendor",
255        "__pycache__",
256        ".next",
257        ".nuxt",
258        "coverage",
259    ];
260
261    // Collect all entries first
262    let mut entries: Vec<_> = WalkDir::new(dir_path)
263        .into_iter()
264        .filter_entry(|e| {
265            if e.file_type().is_dir() {
266                let dir_name = e.file_name().to_string_lossy();
267                !skip_dirs.contains(&dir_name.as_ref())
268            } else {
269                true
270            }
271        })
272        .filter_map(|e| e.ok())
273        .collect();
274
275    // Sort entries by path for consistent cross-platform behavior
276    entries.sort_by(|a, b| a.path().cmp(b.path()));
277
278    for entry in entries {
279        if entry.file_type().is_file()
280            && let Ok(comments) = scan_file(entry.path())
281        {
282            for comment in comments {
283                // Get the feature path from the properties, or infer from file path
284                let feature_path = comment
285                    .properties
286                    .get("feature")
287                    .cloned()
288                    .or_else(|| infer_feature_path_from_file(entry.path(), dir_path));
289
290                if let Some(feature_path) = feature_path {
291                    feature_metadata
292                        .entry(feature_path)
293                        .or_default()
294                        .entry(comment.metadata_key)
295                        .or_default()
296                        .push(comment.properties);
297                }
298            }
299        }
300    }
301
302    Ok(feature_metadata)
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_extract_comment_content_single_line() {
311        let patterns = vec![CommentPattern::LineComment("//")];
312        let line = "// This is a comment";
313        assert_eq!(
314            extract_comment_content(line, &patterns),
315            Some("This is a comment".to_string())
316        );
317    }
318
319    #[test]
320    fn test_extract_comment_content_block() {
321        let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
322        let line = "/* This is a block comment */";
323        assert_eq!(
324            extract_comment_content(line, &patterns),
325            Some("This is a block comment".to_string())
326        );
327    }
328
329    #[test]
330    fn test_extract_comment_content_hash() {
331        let patterns = vec![CommentPattern::LineComment("#")];
332        let line = "# This is a Python comment";
333        assert_eq!(
334            extract_comment_content(line, &patterns),
335            Some("This is a Python comment".to_string())
336        );
337    }
338
339    #[test]
340    fn test_parse_properties() {
341        let content =
342            "feature:feature-1, type: experiment, owner: #owner, introduced_on: 2025-12-31";
343        let props = parse_properties(content);
344
345        assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
346        assert_eq!(props.get("type"), Some(&"experiment".to_string()));
347        assert_eq!(props.get("owner"), Some(&"#owner".to_string()));
348        assert_eq!(props.get("introduced_on"), Some(&"2025-12-31".to_string()));
349    }
350
351    #[test]
352    fn test_check_line_for_feature_metadata_js_style() {
353        let patterns = vec![CommentPattern::BlockComment("/*", "*/")];
354        let line = "/** --feature-flag feature:feature-1, type: experiment, owner: #owner */";
355
356        let result = check_line_for_feature_metadata(line, &patterns);
357        assert!(result.is_some());
358
359        let (metadata_key, props) = result.unwrap();
360        assert_eq!(metadata_key, "flag");
361        assert_eq!(props.get("feature"), Some(&"feature-1".to_string()));
362        assert_eq!(props.get("type"), Some(&"experiment".to_string()));
363    }
364    #[test]
365    fn test_check_line_for_feature_metadata_rust_style() {
366        let patterns = vec![CommentPattern::LineComment("//")];
367        let line = "// --feature-flag feature:my-feature, enabled: true";
368
369        let result = check_line_for_feature_metadata(line, &patterns);
370        assert!(result.is_some());
371
372        let (metadata_key, props) = result.unwrap();
373        assert_eq!(metadata_key, "flag");
374        assert_eq!(props.get("feature"), Some(&"my-feature".to_string()));
375        assert_eq!(props.get("enabled"), Some(&"true".to_string()));
376    }
377
378    #[test]
379    fn test_check_line_for_feature_metadata_python_style() {
380        let patterns = vec![CommentPattern::LineComment("#")];
381        let line = "# --feature-flag feature:analytics, team: data-team";
382
383        let result = check_line_for_feature_metadata(line, &patterns);
384        assert!(result.is_some());
385
386        let (metadata_key, props) = result.unwrap();
387        assert_eq!(metadata_key, "flag");
388        assert_eq!(props.get("feature"), Some(&"analytics".to_string()));
389        assert_eq!(props.get("team"), Some(&"data-team".to_string()));
390    }
391
392    #[test]
393    fn test_no_feature_metadata_in_regular_comment() {
394        let patterns = vec![CommentPattern::LineComment("//")];
395        let line = "// This is just a regular comment";
396
397        assert!(check_line_for_feature_metadata(line, &patterns).is_none());
398    }
399
400    #[test]
401    fn test_different_metadata_keys() {
402        let patterns = vec![CommentPattern::LineComment("//")];
403
404        let line1 = "// --feature-experiment feature:test-feature, status: active";
405        let result1 = check_line_for_feature_metadata(line1, &patterns);
406        assert!(result1.is_some());
407        let (metadata_key1, _) = result1.unwrap();
408        assert_eq!(metadata_key1, "experiment");
409
410        let line2 = "// --feature-toggle feature:another-feature, enabled: true";
411        let result2 = check_line_for_feature_metadata(line2, &patterns);
412        assert!(result2.is_some());
413        let (metadata_key2, _) = result2.unwrap();
414        assert_eq!(metadata_key2, "toggle");
415    }
416}