ricecoder_orchestration/analyzers/
impact_analyzer.rs

1//! Impact analysis for changes across projects
2
3use crate::error::Result;
4use crate::models::{ImpactDetail, ImpactLevel, ImpactReport};
5use std::collections::{HashMap, HashSet, VecDeque};
6
7/// Represents a change to a project
8#[derive(Debug, Clone)]
9pub struct ProjectChange {
10    /// Identifier for the change
11    pub change_id: String,
12
13    /// Project that was changed
14    pub project: String,
15
16    /// Type of change (e.g., "api", "dependency", "config")
17    pub change_type: String,
18
19    /// Description of the change
20    pub description: String,
21
22    /// Whether this is a breaking change
23    pub is_breaking: bool,
24}
25
26/// Analyzes the impact of changes on dependent projects
27#[derive(Debug, Clone)]
28pub struct ImpactAnalyzer {
29    /// Adjacency list for forward dependencies (project -> dependents)
30    dependents_map: HashMap<String, Vec<String>>,
31
32    /// All projects in the workspace
33    projects: HashSet<String>,
34}
35
36impl ImpactAnalyzer {
37    /// Creates a new impact analyzer
38    pub fn new() -> Self {
39        Self {
40            dependents_map: HashMap::new(),
41            projects: HashSet::new(),
42        }
43    }
44
45    /// Adds a project to the analyzer
46    pub fn add_project(&mut self, project_name: String) {
47        self.projects.insert(project_name.clone());
48        self.dependents_map.entry(project_name).or_default();
49    }
50
51    /// Adds a dependency relationship (from depends on to)
52    pub fn add_dependency(&mut self, from: String, to: String) {
53        // Ensure both projects exist
54        self.projects.insert(from.clone());
55        self.projects.insert(to.clone());
56
57        // Add to dependents map: if 'to' changes, 'from' is affected
58        let dependents = self.dependents_map.entry(to).or_default();
59        
60        // Only add if not already present (avoid duplicates)
61        if !dependents.contains(&from) {
62            dependents.push(from);
63        }
64    }
65
66    /// Analyzes the impact of a change on dependent projects
67    pub fn analyze_impact(&self, change: &ProjectChange) -> Result<ImpactReport> {
68        // Find all affected projects
69        let affected_projects = self.find_affected_projects(&change.project);
70
71        // Determine impact level based on change type and breaking status
72        let impact_level = self.determine_impact_level(change);
73
74        // Generate detailed impact information
75        let details = self.generate_impact_details(change, &affected_projects);
76
77        Ok(ImpactReport {
78            change_id: change.change_id.clone(),
79            affected_projects: affected_projects.clone(),
80            impact_level,
81            details,
82        })
83    }
84
85    /// Finds all projects affected by a change to a specific project
86    fn find_affected_projects(&self, changed_project: &str) -> Vec<String> {
87        let mut affected = HashSet::new();
88        let mut visited = HashSet::new();
89        let mut queue = VecDeque::new();
90
91        queue.push_back(changed_project.to_string());
92
93        while let Some(current) = queue.pop_front() {
94            if visited.contains(&current) {
95                continue;
96            }
97            visited.insert(current.clone());
98
99            // Find all projects that depend on the current project
100            if let Some(dependents) = self.dependents_map.get(&current) {
101                for dependent in dependents {
102                    if !visited.contains(dependent) {
103                        affected.insert(dependent.clone());
104                        queue.push_back(dependent.clone());
105                    }
106                }
107            }
108        }
109
110        affected.into_iter().collect()
111    }
112
113    /// Determines the impact level of a change
114    fn determine_impact_level(&self, change: &ProjectChange) -> ImpactLevel {
115        match (change.change_type.as_str(), change.is_breaking) {
116            // Breaking API changes have critical impact
117            ("api", true) => ImpactLevel::Critical,
118            // Breaking dependency changes have high impact
119            ("dependency", true) => ImpactLevel::High,
120            // Breaking config changes have high impact
121            ("config", true) => ImpactLevel::High,
122            // Breaking other changes have high impact
123            (_, true) => ImpactLevel::High,
124            // Non-breaking API changes have medium impact
125            ("api", false) => ImpactLevel::Medium,
126            // Dependency changes have medium impact
127            ("dependency", false) => ImpactLevel::Medium,
128            // Config changes have low impact
129            ("config", false) => ImpactLevel::Low,
130            // Other changes have low impact
131            _ => ImpactLevel::Low,
132        }
133    }
134
135    /// Generates detailed impact information for affected projects
136    fn generate_impact_details(
137        &self,
138        change: &ProjectChange,
139        affected_projects: &[String],
140    ) -> Vec<ImpactDetail> {
141        affected_projects
142            .iter()
143            .map(|project| {
144                let reason = self.generate_impact_reason(change);
145                let required_actions = self.generate_required_actions(change);
146
147                ImpactDetail {
148                    project: project.clone(),
149                    reason,
150                    required_actions,
151                }
152            })
153            .collect()
154    }
155
156    /// Generates a reason for the impact
157    fn generate_impact_reason(&self, change: &ProjectChange) -> String {
158        if change.is_breaking {
159            format!(
160                "Breaking {} change in {}: {}",
161                change.change_type, change.project, change.description
162            )
163        } else {
164            format!(
165                "Non-breaking {} change in {}: {}",
166                change.change_type, change.project, change.description
167            )
168        }
169    }
170
171    /// Generates required actions to address the impact
172    fn generate_required_actions(&self, change: &ProjectChange) -> Vec<String> {
173        let mut actions = vec!["Review the change".to_string()];
174
175        match change.change_type.as_str() {
176            "api" => {
177                actions.push("Update API usage".to_string());
178                if change.is_breaking {
179                    actions.push("Update imports and function calls".to_string());
180                }
181            }
182            "dependency" => {
183                actions.push("Update dependency version".to_string());
184                if change.is_breaking {
185                    actions.push("Review breaking changes in dependency".to_string());
186                }
187            }
188            "config" => {
189                actions.push("Update configuration".to_string());
190            }
191            _ => {
192                actions.push("Verify compatibility".to_string());
193            }
194        }
195
196        actions.push("Run tests".to_string());
197
198        actions
199    }
200
201    /// Analyzes impact for multiple changes
202    pub fn analyze_multiple_impacts(
203        &self,
204        changes: &[ProjectChange],
205    ) -> Result<Vec<ImpactReport>> {
206        changes
207            .iter()
208            .map(|change| self.analyze_impact(change))
209            .collect()
210    }
211
212    /// Gets all projects that would be affected by a change to a specific project
213    pub fn get_affected_projects(&self, project: &str) -> Vec<String> {
214        self.find_affected_projects(project)
215    }
216
217    /// Gets the number of projects affected by a change
218    pub fn count_affected_projects(&self, project: &str) -> usize {
219        self.find_affected_projects(project).len()
220    }
221
222    /// Checks if a project would be affected by a change
223    pub fn is_affected(&self, changed_project: &str, target_project: &str) -> bool {
224        self.find_affected_projects(changed_project)
225            .contains(&target_project.to_string())
226    }
227
228    /// Gets all projects in the analyzer
229    pub fn get_projects(&self) -> Vec<String> {
230        self.projects.iter().cloned().collect()
231    }
232
233    /// Gets the dependency map
234    pub fn get_dependents_map(&self) -> &HashMap<String, Vec<String>> {
235        &self.dependents_map
236    }
237
238    /// Clears all data from the analyzer
239    pub fn clear(&mut self) {
240        self.dependents_map.clear();
241        self.projects.clear();
242    }
243}
244
245impl Default for ImpactAnalyzer {
246    fn default() -> Self {
247        Self::new()
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn test_create_analyzer() {
257        let analyzer = ImpactAnalyzer::new();
258        assert_eq!(analyzer.get_projects().len(), 0);
259    }
260
261    #[test]
262    fn test_add_project() {
263        let mut analyzer = ImpactAnalyzer::new();
264        analyzer.add_project("project-a".to_string());
265
266        assert_eq!(analyzer.get_projects().len(), 1);
267        assert!(analyzer.get_projects().contains(&"project-a".to_string()));
268    }
269
270    #[test]
271    fn test_add_dependency() {
272        let mut analyzer = ImpactAnalyzer::new();
273        analyzer.add_project("project-a".to_string());
274        analyzer.add_project("project-b".to_string());
275        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
276
277        let affected = analyzer.get_affected_projects("project-b");
278        assert_eq!(affected.len(), 1);
279        assert!(affected.contains(&"project-a".to_string()));
280    }
281
282    #[test]
283    fn test_analyze_breaking_api_change() {
284        let mut analyzer = ImpactAnalyzer::new();
285        analyzer.add_project("project-a".to_string());
286        analyzer.add_project("project-b".to_string());
287        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
288
289        let change = ProjectChange {
290            change_id: "change-1".to_string(),
291            project: "project-b".to_string(),
292            change_type: "api".to_string(),
293            description: "Removed deprecated function".to_string(),
294            is_breaking: true,
295        };
296
297        let report = analyzer.analyze_impact(&change).unwrap();
298
299        assert_eq!(report.change_id, "change-1");
300        assert_eq!(report.affected_projects.len(), 1);
301        assert_eq!(report.impact_level, ImpactLevel::Critical);
302        assert!(report.details[0].required_actions.len() > 0);
303    }
304
305    #[test]
306    fn test_analyze_non_breaking_api_change() {
307        let mut analyzer = ImpactAnalyzer::new();
308        analyzer.add_project("project-a".to_string());
309        analyzer.add_project("project-b".to_string());
310        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
311
312        let change = ProjectChange {
313            change_id: "change-1".to_string(),
314            project: "project-b".to_string(),
315            change_type: "api".to_string(),
316            description: "Added new function".to_string(),
317            is_breaking: false,
318        };
319
320        let report = analyzer.analyze_impact(&change).unwrap();
321
322        assert_eq!(report.affected_projects.len(), 1);
323        assert_eq!(report.impact_level, ImpactLevel::Medium);
324    }
325
326    #[test]
327    fn test_analyze_config_change() {
328        let mut analyzer = ImpactAnalyzer::new();
329        analyzer.add_project("project-a".to_string());
330        analyzer.add_project("project-b".to_string());
331        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
332
333        let change = ProjectChange {
334            change_id: "change-1".to_string(),
335            project: "project-b".to_string(),
336            change_type: "config".to_string(),
337            description: "Updated configuration".to_string(),
338            is_breaking: false,
339        };
340
341        let report = analyzer.analyze_impact(&change).unwrap();
342
343        assert_eq!(report.affected_projects.len(), 1);
344        assert_eq!(report.impact_level, ImpactLevel::Low);
345    }
346
347    #[test]
348    fn test_transitive_impact() {
349        let mut analyzer = ImpactAnalyzer::new();
350        analyzer.add_project("project-a".to_string());
351        analyzer.add_project("project-b".to_string());
352        analyzer.add_project("project-c".to_string());
353
354        // A depends on B, B depends on C
355        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
356        analyzer.add_dependency("project-b".to_string(), "project-c".to_string());
357
358        let change = ProjectChange {
359            change_id: "change-1".to_string(),
360            project: "project-c".to_string(),
361            change_type: "api".to_string(),
362            description: "API change".to_string(),
363            is_breaking: true,
364        };
365
366        let report = analyzer.analyze_impact(&change).unwrap();
367
368        // Both A and B should be affected
369        assert_eq!(report.affected_projects.len(), 2);
370        assert!(report.affected_projects.contains(&"project-a".to_string()));
371        assert!(report.affected_projects.contains(&"project-b".to_string()));
372    }
373
374    #[test]
375    fn test_no_affected_projects() {
376        let mut analyzer = ImpactAnalyzer::new();
377        analyzer.add_project("project-a".to_string());
378        analyzer.add_project("project-b".to_string());
379
380        let change = ProjectChange {
381            change_id: "change-1".to_string(),
382            project: "project-a".to_string(),
383            change_type: "api".to_string(),
384            description: "API change".to_string(),
385            is_breaking: true,
386        };
387
388        let report = analyzer.analyze_impact(&change).unwrap();
389
390        assert_eq!(report.affected_projects.len(), 0);
391    }
392
393    #[test]
394    fn test_is_affected() {
395        let mut analyzer = ImpactAnalyzer::new();
396        analyzer.add_project("project-a".to_string());
397        analyzer.add_project("project-b".to_string());
398        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
399
400        assert!(analyzer.is_affected("project-b", "project-a"));
401        assert!(!analyzer.is_affected("project-a", "project-b"));
402    }
403
404    #[test]
405    fn test_count_affected_projects() {
406        let mut analyzer = ImpactAnalyzer::new();
407        analyzer.add_project("project-a".to_string());
408        analyzer.add_project("project-b".to_string());
409        analyzer.add_project("project-c".to_string());
410
411        analyzer.add_dependency("project-a".to_string(), "project-c".to_string());
412        analyzer.add_dependency("project-b".to_string(), "project-c".to_string());
413
414        assert_eq!(analyzer.count_affected_projects("project-c"), 2);
415    }
416
417    #[test]
418    fn test_analyze_multiple_impacts() {
419        let mut analyzer = ImpactAnalyzer::new();
420        analyzer.add_project("project-a".to_string());
421        analyzer.add_project("project-b".to_string());
422        analyzer.add_project("project-c".to_string());
423
424        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
425        analyzer.add_dependency("project-b".to_string(), "project-c".to_string());
426
427        let changes = vec![
428            ProjectChange {
429                change_id: "change-1".to_string(),
430                project: "project-b".to_string(),
431                change_type: "api".to_string(),
432                description: "API change".to_string(),
433                is_breaking: true,
434            },
435            ProjectChange {
436                change_id: "change-2".to_string(),
437                project: "project-c".to_string(),
438                change_type: "config".to_string(),
439                description: "Config change".to_string(),
440                is_breaking: false,
441            },
442        ];
443
444        let reports = analyzer.analyze_multiple_impacts(&changes).unwrap();
445
446        assert_eq!(reports.len(), 2);
447        // Change to B affects A (1 project)
448        assert_eq!(reports[0].affected_projects.len(), 1);
449        // Change to C affects B and A (2 projects)
450        assert_eq!(reports[1].affected_projects.len(), 2);
451    }
452
453    #[test]
454    fn test_clear_analyzer() {
455        let mut analyzer = ImpactAnalyzer::new();
456        analyzer.add_project("project-a".to_string());
457        analyzer.add_project("project-b".to_string());
458
459        assert_eq!(analyzer.get_projects().len(), 2);
460
461        analyzer.clear();
462
463        assert_eq!(analyzer.get_projects().len(), 0);
464    }
465
466    #[test]
467    fn test_impact_detail_generation() {
468        let mut analyzer = ImpactAnalyzer::new();
469        analyzer.add_project("project-a".to_string());
470        analyzer.add_project("project-b".to_string());
471        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
472
473        let change = ProjectChange {
474            change_id: "change-1".to_string(),
475            project: "project-b".to_string(),
476            change_type: "api".to_string(),
477            description: "Removed function".to_string(),
478            is_breaking: true,
479        };
480
481        let report = analyzer.analyze_impact(&change).unwrap();
482
483        assert_eq!(report.details.len(), 1);
484        assert_eq!(report.details[0].project, "project-a");
485        assert!(report.details[0].reason.contains("Breaking"));
486        assert!(report.details[0].required_actions.len() > 0);
487    }
488
489    #[test]
490    fn test_dependency_change_impact() {
491        let mut analyzer = ImpactAnalyzer::new();
492        analyzer.add_project("project-a".to_string());
493        analyzer.add_project("project-b".to_string());
494        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
495
496        let change = ProjectChange {
497            change_id: "change-1".to_string(),
498            project: "project-b".to_string(),
499            change_type: "dependency".to_string(),
500            description: "Updated dependency".to_string(),
501            is_breaking: true,
502        };
503
504        let report = analyzer.analyze_impact(&change).unwrap();
505
506        assert_eq!(report.impact_level, ImpactLevel::High);
507    }
508
509    #[test]
510    fn test_complex_dependency_graph() {
511        let mut analyzer = ImpactAnalyzer::new();
512
513        // Create a diamond dependency graph
514        // A -> B -> D
515        // A -> C -> D
516        analyzer.add_project("project-a".to_string());
517        analyzer.add_project("project-b".to_string());
518        analyzer.add_project("project-c".to_string());
519        analyzer.add_project("project-d".to_string());
520
521        analyzer.add_dependency("project-a".to_string(), "project-b".to_string());
522        analyzer.add_dependency("project-a".to_string(), "project-c".to_string());
523        analyzer.add_dependency("project-b".to_string(), "project-d".to_string());
524        analyzer.add_dependency("project-c".to_string(), "project-d".to_string());
525
526        let change = ProjectChange {
527            change_id: "change-1".to_string(),
528            project: "project-d".to_string(),
529            change_type: "api".to_string(),
530            description: "API change".to_string(),
531            is_breaking: true,
532        };
533
534        let report = analyzer.analyze_impact(&change).unwrap();
535
536        // A, B, and C should be affected (A depends on B and C, B and C depend on D)
537        assert_eq!(report.affected_projects.len(), 3);
538        assert!(report.affected_projects.contains(&"project-a".to_string()));
539        assert!(report.affected_projects.contains(&"project-b".to_string()));
540        assert!(report.affected_projects.contains(&"project-c".to_string()));
541    }
542}