qail_core/analyzer/
impact.rs

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