qail_core/analyzer/
impact.rs

1//! Migration impact analysis.
2
3use super::scanner::CodeReference;
4use crate::ast::{Action, QailCmd};
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    /// 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
78                .entry(code_ref.table.clone())
79                .or_default()
80                .push(code_ref);
81
82            for col in &code_ref.columns {
83                column_refs
84                    .entry((code_ref.table.clone(), col.clone()))
85                    .or_default()
86                    .push(code_ref);
87            }
88        }
89
90        // Analyze each migration command
91        for cmd in commands {
92            match cmd.action {
93                Action::Drop => {
94                    // Table being dropped
95                    if let Some(refs) = table_refs.get(&cmd.table)
96                        && !refs.is_empty()
97                    {
98                        impact.breaking_changes.push(BreakingChange::DroppedTable {
99                            table: cmd.table.clone(),
100                            references: refs.iter().map(|r| (*r).clone()).collect(),
101                        });
102                    }
103                }
104                Action::AlterDrop => {
105                    // Column being dropped
106                    for col_expr in &cmd.columns {
107                        if let crate::ast::Expr::Named(col_name) = col_expr {
108                            let key = (cmd.table.clone(), col_name.clone());
109                            if let Some(refs) = column_refs.get(&key)
110                                && !refs.is_empty()
111                            {
112                                impact.breaking_changes.push(BreakingChange::DroppedColumn {
113                                    table: cmd.table.clone(),
114                                    column: col_name.clone(),
115                                    references: refs.iter().map(|r| (*r).clone()).collect(),
116                                });
117                            }
118                        }
119                    }
120                }
121                Action::Mod => {
122                    // Rename operation - check for references to old name
123                    // Would need to parse the rename details from the command
124                    // For now, flag any table with Mod action
125                    if let Some(refs) = table_refs.get(&cmd.table)
126                        && !refs.is_empty()
127                    {
128                        impact.breaking_changes.push(BreakingChange::RenamedColumn {
129                            table: cmd.table.clone(),
130                            old_name: "unknown".to_string(),
131                            new_name: "unknown".to_string(),
132                            references: refs.iter().map(|r| (*r).clone()).collect(),
133                        });
134                    }
135                }
136                _ => {}
137            }
138        }
139
140        // Count affected files
141        let mut affected: std::collections::HashSet<_> = std::collections::HashSet::new();
142        for change in &impact.breaking_changes {
143            match change {
144                BreakingChange::DroppedColumn { references, .. }
145                | BreakingChange::DroppedTable { references, .. }
146                | BreakingChange::RenamedColumn { references, .. }
147                | BreakingChange::TypeChanged { references, .. } => {
148                    for r in references {
149                        affected.insert(r.file.clone());
150                    }
151                }
152            }
153        }
154        impact.affected_files = affected.len();
155        impact.safe_to_run = impact.breaking_changes.is_empty();
156
157        impact
158    }
159
160    /// Generate a human-readable report.
161    pub fn report(&self) -> String {
162        let mut output = String::new();
163
164        if self.safe_to_run {
165            output.push_str("✓ Migration is safe to run\n");
166            return output;
167        }
168
169        output.push_str("⚠️  BREAKING CHANGES DETECTED\n\n");
170        output.push_str(&format!("Affected files: {}\n\n", self.affected_files));
171
172        for change in &self.breaking_changes {
173            match change {
174                BreakingChange::DroppedColumn {
175                    table,
176                    column,
177                    references,
178                } => {
179                    output.push_str(&format!(
180                        "DROP COLUMN {}.{} ({} references)\n",
181                        table,
182                        column,
183                        references.len()
184                    ));
185                    for r in references.iter().take(5) {
186                        output.push_str(&format!(
187                            "  ❌ {}:{} → {}\n",
188                            r.file.display(),
189                            r.line,
190                            r.snippet
191                        ));
192                    }
193                    if references.len() > 5 {
194                        output.push_str(&format!("  ... and {} more\n", references.len() - 5));
195                    }
196                    output.push('\n');
197                }
198                BreakingChange::DroppedTable { table, references } => {
199                    output.push_str(&format!(
200                        "DROP TABLE {} ({} references)\n",
201                        table,
202                        references.len()
203                    ));
204                    for r in references.iter().take(5) {
205                        output.push_str(&format!(
206                            "  ❌ {}:{} → {}\n",
207                            r.file.display(),
208                            r.line,
209                            r.snippet
210                        ));
211                    }
212                    output.push('\n');
213                }
214                BreakingChange::RenamedColumn {
215                    table,
216                    old_name,
217                    new_name,
218                    references,
219                } => {
220                    output.push_str(&format!(
221                        "RENAME {}.{} → {} ({} references)\n",
222                        table,
223                        old_name,
224                        new_name,
225                        references.len()
226                    ));
227                    for r in references.iter().take(5) {
228                        output.push_str(&format!(
229                            "  ⚠️  {}:{} → {}\n",
230                            r.file.display(),
231                            r.line,
232                            r.snippet
233                        ));
234                    }
235                    output.push('\n');
236                }
237                BreakingChange::TypeChanged {
238                    table,
239                    column,
240                    old_type,
241                    new_type,
242                    references,
243                } => {
244                    output.push_str(&format!(
245                        "TYPE CHANGE {}.{}: {} → {} ({} references)\n",
246                        table,
247                        column,
248                        old_type,
249                        new_type,
250                        references.len()
251                    ));
252                    for r in references.iter().take(5) {
253                        output.push_str(&format!(
254                            "  ⚠️  {}:{} → {}\n",
255                            r.file.display(),
256                            r.line,
257                            r.snippet
258                        ));
259                    }
260                    output.push('\n');
261                }
262            }
263        }
264
265        output
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use std::path::PathBuf;
273
274    #[test]
275    fn test_detect_dropped_table() {
276        let cmd = QailCmd {
277            action: Action::Drop,
278            table: "users".to_string(),
279            ..Default::default()
280        };
281
282        let code_ref = CodeReference {
283            file: PathBuf::from("src/handlers.rs"),
284            line: 42,
285            table: "users".to_string(),
286            columns: vec!["name".to_string()],
287            query_type: super::super::scanner::QueryType::Qail,
288            snippet: "get::users".to_string(),
289        };
290
291        let old_schema = Schema::new();
292        let new_schema = Schema::new();
293
294        let impact = MigrationImpact::analyze(&[cmd], &[code_ref], &old_schema, &new_schema);
295
296        assert!(!impact.safe_to_run);
297        assert_eq!(impact.breaking_changes.len(), 1);
298    }
299}