1use super::scanner::CodeReference;
4use crate::migrate::Schema;
5use crate::ast::{Action, QailCmd};
6use std::collections::HashMap;
7
8#[derive(Debug, Default)]
10pub struct MigrationImpact {
11 pub breaking_changes: Vec<BreakingChange>,
13 pub warnings: Vec<Warning>,
15 pub safe_to_run: bool,
17 pub affected_files: usize,
19}
20
21#[derive(Debug)]
23pub enum BreakingChange {
24 DroppedColumn {
26 table: String,
27 column: String,
28 references: Vec<CodeReference>,
29 },
30 DroppedTable {
32 table: String,
33 references: Vec<CodeReference>,
34 },
35 RenamedColumn {
37 table: String,
38 old_name: String,
39 new_name: String,
40 references: Vec<CodeReference>,
41 },
42 TypeChanged {
44 table: String,
45 column: String,
46 old_type: String,
47 new_type: String,
48 references: Vec<CodeReference>,
49 },
50}
51
52#[derive(Debug)]
54pub enum Warning {
55 OrphanedReference {
57 table: String,
58 references: Vec<CodeReference>,
59 },
60}
61
62impl MigrationImpact {
63 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 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 for cmd in commands {
90 match cmd.action {
91 Action::Drop => {
92 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 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 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 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 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}