qail_core/analyzer/
impact.rs

1//! Migration impact analysis.
2
3use super::scanner::CodeReference;
4use crate::migrate::Schema;
5use crate::ast::{Action, QailCmd};
6use std::collections::HashMap;
7
8/// Result of analyzing migration impact on codebase.
9#[derive(Debug, Default)]
10pub struct MigrationImpact {
11    /// Breaking changes that will cause runtime errors
12    pub breaking_changes: Vec<BreakingChange>,
13    /// Warnings that may cause issues
14    pub warnings: Vec<Warning>,
15    /// Whether it's safe to run the migration
16    pub safe_to_run: bool,
17    /// Total number of affected files
18    pub affected_files: usize,
19}
20
21/// A breaking change detected in the migration.
22#[derive(Debug)]
23pub enum BreakingChange {
24    /// A column is being dropped that is still referenced in code
25    DroppedColumn {
26        table: String,
27        column: String,
28        references: Vec<CodeReference>,
29    },
30    /// A table is being dropped that is still referenced in code
31    DroppedTable {
32        table: String,
33        references: Vec<CodeReference>,
34    },
35    /// A column is being renamed (requires code update)
36    RenamedColumn {
37        table: String,
38        old_name: String,
39        new_name: String,
40        references: Vec<CodeReference>,
41    },
42    /// A column type is changing (may cause runtime errors)
43    TypeChanged {
44        table: String,
45        column: String,
46        old_type: String,
47        new_type: String,
48        references: Vec<CodeReference>,
49    },
50}
51
52/// A warning about the migration.
53#[derive(Debug)]
54pub enum Warning {
55    /// Table is referenced but not in new schema
56    OrphanedReference {
57        table: String,
58        references: Vec<CodeReference>,
59    },
60}
61
62impl MigrationImpact {
63    /// Analyze migration commands against codebase references.
64    pub fn analyze(
65        commands: &[QailCmd],
66        code_refs: &[CodeReference],
67        _old_schema: &Schema,
68        _new_schema: &Schema,
69    ) -> Self {
70        let mut impact = MigrationImpact::default();
71        
72        // Build lookup maps
73        let mut table_refs: HashMap<String, Vec<&CodeReference>> = HashMap::new();
74        let mut column_refs: HashMap<(String, String), Vec<&CodeReference>> = HashMap::new();
75        
76        for code_ref in code_refs {
77            table_refs.entry(code_ref.table.clone())
78                .or_default()
79                .push(code_ref);
80            
81            for col in &code_ref.columns {
82                column_refs.entry((code_ref.table.clone(), col.clone()))
83                    .or_default()
84                    .push(code_ref);
85            }
86        }
87        
88        // Analyze each migration command
89        for cmd in commands {
90            match cmd.action {
91                Action::Drop => {
92                    // Table being dropped
93                    if let Some(refs) = table_refs.get(&cmd.table) {
94                        if !refs.is_empty() {
95                            impact.breaking_changes.push(BreakingChange::DroppedTable {
96                                table: cmd.table.clone(),
97                                references: refs.iter().map(|r| (*r).clone()).collect(),
98                            });
99                        }
100                    }
101                },
102                Action::AlterDrop => {
103                    // Column being dropped
104                    for col_expr in &cmd.columns {
105                        if let crate::ast::Expr::Named(col_name) = col_expr {
106                            let key = (cmd.table.clone(), col_name.clone());
107                            if let Some(refs) = column_refs.get(&key) {
108                                if !refs.is_empty() {
109                                    impact.breaking_changes.push(BreakingChange::DroppedColumn {
110                                        table: cmd.table.clone(),
111                                        column: col_name.clone(),
112                                        references: refs.iter().map(|r| (*r).clone()).collect(),
113                                    });
114                                }
115                            }
116                        }
117                    }
118                },
119                Action::Mod => {
120                    // Rename operation - check for references to old name
121                    // Would need to parse the rename details from the command
122                    // For now, flag any table with Mod action
123                    if let Some(refs) = table_refs.get(&cmd.table) {
124                        if !refs.is_empty() {
125                            impact.breaking_changes.push(BreakingChange::RenamedColumn {
126                                table: cmd.table.clone(),
127                                old_name: "unknown".to_string(),
128                                new_name: "unknown".to_string(),
129                                references: refs.iter().map(|r| (*r).clone()).collect(),
130                            });
131                        }
132                    }
133                },
134                _ => {}
135            }
136        }
137        
138        // Count affected files
139        let mut affected: std::collections::HashSet<_> = std::collections::HashSet::new();
140        for change in &impact.breaking_changes {
141            match change {
142                BreakingChange::DroppedColumn { references, .. } |
143                BreakingChange::DroppedTable { references, .. } |
144                BreakingChange::RenamedColumn { references, .. } |
145                BreakingChange::TypeChanged { references, .. } => {
146                    for r in references {
147                        affected.insert(r.file.clone());
148                    }
149                }
150            }
151        }
152        impact.affected_files = affected.len();
153        impact.safe_to_run = impact.breaking_changes.is_empty();
154        
155        impact
156    }
157    
158    /// Generate a human-readable report.
159    pub fn report(&self) -> String {
160        let mut output = String::new();
161        
162        if self.safe_to_run {
163            output.push_str("✓ Migration is safe to run\n");
164            return output;
165        }
166        
167        output.push_str("⚠️  BREAKING CHANGES DETECTED\n\n");
168        output.push_str(&format!("Affected files: {}\n\n", self.affected_files));
169        
170        for change in &self.breaking_changes {
171            match change {
172                BreakingChange::DroppedColumn { table, column, references } => {
173                    output.push_str(&format!("DROP COLUMN {}.{} ({} references)\n", 
174                        table, column, references.len()));
175                    for r in references.iter().take(5) {
176                        output.push_str(&format!("  ❌ {}:{} → {}\n", 
177                            r.file.display(), r.line, r.snippet));
178                    }
179                    if references.len() > 5 {
180                        output.push_str(&format!("  ... and {} more\n", references.len() - 5));
181                    }
182                    output.push('\n');
183                },
184                BreakingChange::DroppedTable { table, references } => {
185                    output.push_str(&format!("DROP TABLE {} ({} references)\n", 
186                        table, references.len()));
187                    for r in references.iter().take(5) {
188                        output.push_str(&format!("  ❌ {}:{} → {}\n", 
189                            r.file.display(), r.line, r.snippet));
190                    }
191                    output.push('\n');
192                },
193                BreakingChange::RenamedColumn { table, old_name, new_name, references } => {
194                    output.push_str(&format!("RENAME {}.{} → {} ({} references)\n", 
195                        table, old_name, new_name, references.len()));
196                    for r in references.iter().take(5) {
197                        output.push_str(&format!("  ⚠️  {}:{} → {}\n", 
198                            r.file.display(), r.line, r.snippet));
199                    }
200                    output.push('\n');
201                },
202                BreakingChange::TypeChanged { table, column, old_type, new_type, references } => {
203                    output.push_str(&format!("TYPE CHANGE {}.{}: {} → {} ({} references)\n", 
204                        table, column, old_type, new_type, references.len()));
205                    for r in references.iter().take(5) {
206                        output.push_str(&format!("  ⚠️  {}:{} → {}\n", 
207                            r.file.display(), r.line, r.snippet));
208                    }
209                    output.push('\n');
210                },
211            }
212        }
213        
214        output
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::path::PathBuf;
222    
223    #[test]
224    fn test_detect_dropped_table() {
225        let cmd = QailCmd {
226            action: Action::Drop,
227            table: "users".to_string(),
228            ..Default::default()
229        };
230        
231        let code_ref = CodeReference {
232            file: PathBuf::from("src/handlers.rs"),
233            line: 42,
234            table: "users".to_string(),
235            columns: vec!["name".to_string()],
236            query_type: super::super::scanner::QueryType::Qail,
237            snippet: "get::users".to_string(),
238        };
239        
240        let old_schema = Schema::new();
241        let new_schema = Schema::new();
242        
243        let impact = MigrationImpact::analyze(&[cmd], &[code_ref], &old_schema, &new_schema);
244        
245        assert!(!impact.safe_to_run);
246        assert_eq!(impact.breaking_changes.len(), 1);
247    }
248}