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