Skip to main content

features_cli/
dependency_resolver.rs

1//! Module for resolving dependencies between features
2//!
3//! This module takes import statements and determines which features they belong to,
4//! and what type of relationship exists between features (parent, child, sibling).
5
6use crate::import_detector::{ImportStatement, resolve_import_path};
7use crate::models::{Dependency, DependencyType};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// Represents a feature with its path for dependency resolution
12#[derive(Debug, Clone)]
13pub struct FeatureInfo {
14    pub name: String,
15    pub path: PathBuf,
16}
17
18/// Build a map of file paths to their containing features (using feature path as identifier)
19pub fn build_file_to_feature_map(
20    features: &[FeatureInfo],
21    base_path: &Path,
22) -> HashMap<PathBuf, String> {
23    let mut map = HashMap::new();
24
25    for feature in features {
26        let feature_path = base_path.join(&feature.path);
27
28        // Map all files within this feature directory using the feature's path as identifier
29        if std::fs::read_dir(&feature_path).is_ok() {
30            map_directory_files(&feature_path, &feature.path.to_string_lossy(), &mut map);
31        }
32    }
33
34    map
35}
36
37/// Recursively map all files in a directory to a feature path
38fn map_directory_files(dir: &Path, feature_path: &str, map: &mut HashMap<PathBuf, String>) {
39    if let Ok(entries) = std::fs::read_dir(dir) {
40        for entry in entries.flatten() {
41            let path = entry.path();
42
43            if path.is_file() {
44                // Canonicalize the path to resolve .. and .
45                if let Ok(canonical_path) = path.canonicalize() {
46                    map.insert(canonical_path, feature_path.to_string());
47                } else {
48                    map.insert(path.clone(), feature_path.to_string());
49                }
50            } else if path.is_dir()
51                && let Some(dir_name) = path.file_name().and_then(|n| n.to_str())
52                && !should_skip_directory(dir_name)
53            {
54                map_directory_files(&path, feature_path, map);
55            }
56        }
57    }
58}
59
60/// Check if a directory should be skipped
61fn should_skip_directory(dir_name: &str) -> bool {
62    matches!(
63        dir_name,
64        "node_modules" | "target" | "dist" | "build" | ".git" | "__pycache__" | "coverage"
65    )
66}
67
68/// Determine the relationship type between two features based on their paths
69pub fn determine_dependency_type(
70    source_feature_path: &Path,
71    target_feature_path: &Path,
72) -> DependencyType {
73    // Check if target is a child (descendant) of source
74    if target_feature_path.starts_with(source_feature_path) {
75        return DependencyType::Child;
76    }
77
78    // Check if source is a child (descendant) of target
79    if source_feature_path.starts_with(target_feature_path) {
80        return DependencyType::Parent;
81    }
82
83    // Otherwise, they're siblings
84    DependencyType::Sibling
85}
86
87/// Resolve imports to dependencies for a specific feature
88pub fn resolve_feature_dependencies(
89    _feature_name: &str,
90    feature_path: &Path,
91    base_path: &Path,
92    imports: &[ImportStatement],
93    file_to_feature_map: &HashMap<PathBuf, String>,
94    feature_path_to_name_map: &HashMap<String, String>,
95    file_map: &HashMap<String, PathBuf>,
96) -> Vec<Dependency> {
97    let mut dependencies = Vec::new();
98    let mut seen = std::collections::HashSet::new();
99
100    for import in imports {
101        let source_file = Path::new(&import.file_path);
102
103        // Resolve the import to an actual file path
104        if let Some(resolved_path) =
105            resolve_import_path(&import.imported_path, source_file, base_path, file_map)
106        {
107            // Find which feature this file belongs to (returns feature path)
108            if let Some(target_feature_path_str) = file_to_feature_map.get(&resolved_path) {
109                // Skip if it's the same feature
110                if target_feature_path_str == feature_path.to_string_lossy().as_ref() {
111                    continue;
112                }
113
114                // Create a unique key to avoid duplicates
115                let dep_key = format!(
116                    "{}:{}:{}",
117                    resolved_path.display(),
118                    import.line_number,
119                    target_feature_path_str
120                );
121
122                if seen.contains(&dep_key) {
123                    continue;
124                }
125                seen.insert(dep_key);
126
127                // Get the target feature name from the path (for validation)
128                if feature_path_to_name_map
129                    .get(target_feature_path_str)
130                    .is_some()
131                {
132                    let target_path = PathBuf::from(target_feature_path_str);
133                    let full_target_path = base_path.join(&target_path);
134                    let full_source_path = base_path.join(feature_path);
135
136                    // Determine the dependency type
137                    let dependency_type =
138                        determine_dependency_type(&full_source_path, &full_target_path);
139
140                    // Convert source file path to be relative to base_path
141                    let relative_source_filename = if let Ok(canonical_base) =
142                        base_path.canonicalize()
143                    {
144                        let source_path = Path::new(&import.file_path);
145                        if let Ok(canonical_source) = source_path.canonicalize() {
146                            if let Ok(rel_path) = canonical_source.strip_prefix(&canonical_base) {
147                                rel_path.to_string_lossy().to_string()
148                            } else {
149                                import.file_path.clone()
150                            }
151                        } else {
152                            import.file_path.clone()
153                        }
154                    } else {
155                        import.file_path.clone()
156                    };
157
158                    // Convert target file path to be relative to base_path
159                    let relative_target_filename =
160                        if let Ok(canonical_base) = base_path.canonicalize() {
161                            if let Ok(rel_path) = resolved_path.strip_prefix(&canonical_base) {
162                                rel_path.to_string_lossy().to_string()
163                            } else {
164                                resolved_path.to_string_lossy().to_string()
165                            }
166                        } else {
167                            resolved_path.to_string_lossy().to_string()
168                        };
169
170                    // Create dependency
171                    dependencies.push(Dependency {
172                        source_filename: relative_source_filename,
173                        target_filename: relative_target_filename,
174                        line: import.line_number,
175                        content: import.line_content.clone(),
176                        feature_path: target_feature_path_str.to_string(),
177                        dependency_type,
178                    });
179                }
180            }
181        }
182    }
183
184    dependencies
185}
186
187/// Collect all feature information recursively
188pub fn collect_feature_info(
189    features: &[crate::models::Feature],
190    _parent_path: Option<&Path>,
191    result: &mut Vec<FeatureInfo>,
192) {
193    for feature in features {
194        // Feature paths are already relative to base, not to parent
195        let feature_path = PathBuf::from(&feature.path);
196
197        result.push(FeatureInfo {
198            name: feature.name.clone(),
199            path: feature_path.clone(),
200        });
201
202        // Recursively collect nested features (don't pass parent_path since paths are base-relative)
203        if !feature.features.is_empty() {
204            collect_feature_info(&feature.features, None, result);
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_determine_dependency_type_child() {
215        let source = Path::new("/project/features/parent");
216        let target = Path::new("/project/features/parent/child");
217
218        assert!(matches!(
219            determine_dependency_type(source, target),
220            DependencyType::Child
221        ));
222    }
223
224    #[test]
225    fn test_determine_dependency_type_parent() {
226        let source = Path::new("/project/features/parent/child");
227        let target = Path::new("/project/features/parent");
228
229        assert!(matches!(
230            determine_dependency_type(source, target),
231            DependencyType::Parent
232        ));
233    }
234
235    #[test]
236    fn test_determine_dependency_type_sibling() {
237        let source = Path::new("/project/features/feature-a");
238        let target = Path::new("/project/features/feature-b");
239
240        assert!(matches!(
241            determine_dependency_type(source, target),
242            DependencyType::Sibling
243        ));
244    }
245}