1use super::model::{Answer, Clarification, ClarificationKind, Severity};
2
3#[derive(Clone)]
8pub enum OptionAction {
9 Fixed(Answer),
10 RequiresInput {
11 prompt: String,
12 make_answer: fn(String) -> Answer,
13 },
14}
15
16impl std::fmt::Debug for OptionAction {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Self::Fixed(a) => write!(f, "Fixed({:?})", a),
20 Self::RequiresInput { prompt, .. } => {
21 write!(f, "RequiresInput {{ prompt: {:?} }}", prompt)
22 }
23 }
24 }
25}
26
27#[derive(Debug, Clone)]
28pub struct ClarificationOption {
29 pub label: String,
30 pub action: OptionAction,
31}
32
33#[derive(Debug, Clone)]
36pub struct ClarificationMessage {
37 pub description: String,
38 pub options: Vec<ClarificationOption>,
39}
40
41pub fn clarification_message(clar: &Clarification) -> ClarificationMessage {
44 let tag = severity_tag(&clar.severity);
45 match &clar.kind {
46 ClarificationKind::RenameColumn {
47 table,
48 old,
49 candidates,
50 } => {
51 let description = format!(
52 "{} Column '{}' was removed from '{}'. Was it renamed?",
53 tag, old, table
54 );
55 let mut options: Vec<ClarificationOption> = candidates
56 .iter()
57 .map(|c| ClarificationOption {
58 label: c.clone(),
59 action: OptionAction::Fixed(Answer::RenameTo(c.clone())),
60 })
61 .collect();
62 options.push(ClarificationOption {
63 label: "No, it was dropped".to_string(),
64 action: OptionAction::Fixed(Answer::RenameNo),
65 });
66 ClarificationMessage {
67 description,
68 options,
69 }
70 }
71 ClarificationKind::RenameTable { old, candidates } => {
72 let description = format!("{} Table '{}' was removed. Was it renamed?", tag, old);
73 let mut options: Vec<ClarificationOption> = candidates
74 .iter()
75 .map(|c| ClarificationOption {
76 label: c.clone(),
77 action: OptionAction::Fixed(Answer::RenameTo(c.clone())),
78 })
79 .collect();
80 options.push(ClarificationOption {
81 label: "No, it was dropped".to_string(),
82 action: OptionAction::Fixed(Answer::RenameNo),
83 });
84 ClarificationMessage {
85 description,
86 options,
87 }
88 }
89 ClarificationKind::RenameEnumValue {
90 enum_name,
91 old,
92 candidates,
93 } => {
94 let description = format!(
95 "{} Enum value '{}' was removed from '{}'. Was it renamed?",
96 tag, old, enum_name
97 );
98 let mut options: Vec<ClarificationOption> = candidates
99 .iter()
100 .map(|c| ClarificationOption {
101 label: c.clone(),
102 action: OptionAction::Fixed(Answer::RenameTo(c.clone())),
103 })
104 .collect();
105 options.push(ClarificationOption {
106 label: "No, it was removed".to_string(),
107 action: OptionAction::Fixed(Answer::RenameNo),
108 });
109 ClarificationMessage {
110 description,
111 options,
112 }
113 }
114 ClarificationKind::NotNullAdd {
115 table,
116 column,
117 col_type,
118 } => {
119 let description = format!(
120 "{} Column '{}' ({}) on '{}' is NOT NULL with no default.",
121 tag, column, col_type, table
122 );
123 ClarificationMessage {
124 description,
125 options: vec![
126 ClarificationOption {
127 label: "Provide a one-off default SQL value (e.g. 0, '', now())"
128 .to_string(),
129 action: OptionAction::RequiresInput {
130 prompt: "Default value:".to_string(),
131 make_answer: Answer::NotNullDefault,
132 },
133 },
134 ClarificationOption {
135 label: "Make it nullable instead".to_string(),
136 action: OptionAction::Fixed(Answer::NotNullNullable),
137 },
138 ClarificationOption {
139 label: "Handle manually (will fail on non-empty tables)".to_string(),
140 action: OptionAction::Fixed(Answer::NotNullManual),
141 },
142 ],
143 }
144 }
145 ClarificationKind::NotNullChange { table, column } => {
146 let description = format!(
147 "{} Column '{}' on '{}' changed from nullable to NOT NULL.",
148 tag, column, table
149 );
150 ClarificationMessage {
151 description,
152 options: vec![
153 ClarificationOption {
154 label: "Provide a backfill default for existing NULL rows".to_string(),
155 action: OptionAction::RequiresInput {
156 prompt: "Backfill value:".to_string(),
157 make_answer: Answer::NotNullDefault,
158 },
159 },
160 ClarificationOption {
161 label: "Keep it nullable instead".to_string(),
162 action: OptionAction::Fixed(Answer::NotNullNullable),
163 },
164 ClarificationOption {
165 label: "Handle manually".to_string(),
166 action: OptionAction::Fixed(Answer::NotNullManual),
167 },
168 ],
169 }
170 }
171 ClarificationKind::TypeCast {
172 table,
173 column,
174 from,
175 to,
176 } => {
177 let description = format!(
178 "{} Column '{}' on '{}' changed type: {} -> {}.",
179 tag, column, table, from, to
180 );
181 ClarificationMessage {
182 description,
183 options: vec![
184 ClarificationOption {
185 label: "Provide a CAST expression (e.g. col::integer)".to_string(),
186 action: OptionAction::RequiresInput {
187 prompt: "CAST expression:".to_string(),
188 make_answer: Answer::TypeCast,
189 },
190 },
191 ClarificationOption {
192 label: "Use implicit cast (may fail at apply time)".to_string(),
193 action: OptionAction::Fixed(Answer::TypeCastImplicit),
194 },
195 ],
196 }
197 }
198 ClarificationKind::UnknownType {
199 table,
200 column,
201 type_name,
202 suggested,
203 } => {
204 let description = format!(
205 "{} Column '{}' on '{}' uses unknown type '{}'.",
206 tag, column, table, type_name
207 );
208 let mut options = suggested
209 .iter()
210 .map(|suggestion| ClarificationOption {
211 label: format!("Use {suggestion}"),
212 action: OptionAction::Fixed(Answer::UseType(suggestion.clone())),
213 })
214 .collect::<Vec<_>>();
215 options.push(ClarificationOption {
216 label: format!("Keep {type_name} as a custom/domain/extension type"),
217 action: OptionAction::Fixed(Answer::KeepType),
218 });
219 ClarificationMessage {
220 description,
221 options,
222 }
223 }
224 ClarificationKind::OpaqueEntity { kind, name } => {
225 let description = format!(
226 "{} {:?} '{}' is managed as opaque SQL; changes use coarse create/drop/replace semantics.",
227 tag, kind, name
228 );
229 ClarificationMessage {
230 description,
231 options: vec![ClarificationOption {
232 label: "Accept opaque SQL management for this object".to_string(),
233 action: OptionAction::Fixed(Answer::AcceptRisk),
234 }],
235 }
236 }
237 ClarificationKind::UnmanagedTableOptions { table } => {
238 let description = format!(
239 "{} Table '{}' has unmanaged table options that Gaman will preserve but not model granularly.",
240 tag, table
241 );
242 ClarificationMessage {
243 description,
244 options: vec![ClarificationOption {
245 label: "Accept unmanaged table options".to_string(),
246 action: OptionAction::Fixed(Answer::AcceptRisk),
247 }],
248 }
249 }
250 }
251}
252
253fn severity_tag(s: &Severity) -> &'static str {
254 match s {
255 Severity::Fatal => "[FATAL]",
256 Severity::Warning => "[WARNING]",
257 Severity::Suggestion => "[suggest]",
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn assert_nonempty_message(message: &ClarificationMessage) {
266 assert!(!message.description.is_empty());
267 assert!(!message.options.is_empty());
268 for option in &message.options {
269 assert!(!option.label.is_empty());
270 }
271 }
272
273 #[test]
275 fn rename_column_message_maps_options_to_answers() {
276 let message = clarification_message(&Clarification {
277 id: "rename_col:users:email".to_string(),
278 severity: Severity::Suggestion,
279 kind: ClarificationKind::RenameColumn {
280 table: "users".to_string(),
281 old: "email".to_string(),
282 candidates: vec!["email_address".to_string()],
283 },
284 });
285
286 assert_nonempty_message(&message);
287 assert!(matches!(
288 &message.options[0].action,
289 OptionAction::Fixed(Answer::RenameTo(name)) if name == "email_address"
290 ));
291 assert!(matches!(
292 &message.options[1].action,
293 OptionAction::Fixed(Answer::RenameNo)
294 ));
295 }
296
297 #[test]
299 fn not_null_add_message_maps_options_to_answers() {
300 let message = clarification_message(&Clarification {
301 id: "notnull_add:orders:reference_id".to_string(),
302 severity: Severity::Fatal,
303 kind: ClarificationKind::NotNullAdd {
304 table: "orders".to_string(),
305 column: "reference_id".to_string(),
306 col_type: "integer".to_string(),
307 },
308 });
309
310 assert_nonempty_message(&message);
311 assert_eq!(message.options.len(), 3);
312 assert!(matches!(
313 message.options[0].action,
314 OptionAction::RequiresInput { .. }
315 ));
316 assert!(matches!(
317 message.options[1].action,
318 OptionAction::Fixed(Answer::NotNullNullable)
319 ));
320 assert!(matches!(
321 message.options[2].action,
322 OptionAction::Fixed(Answer::NotNullManual)
323 ));
324 }
325
326 #[test]
328 fn every_clarification_kind_has_message_spec() {
329 let clarifications = vec![
330 Clarification {
331 id: "rename_table:users".to_string(),
332 severity: Severity::Suggestion,
333 kind: ClarificationKind::RenameTable {
334 old: "users".to_string(),
335 candidates: vec!["accounts".to_string()],
336 },
337 },
338 Clarification {
339 id: "rename_enum_value:status:live".to_string(),
340 severity: Severity::Warning,
341 kind: ClarificationKind::RenameEnumValue {
342 enum_name: "status".to_string(),
343 old: "live".to_string(),
344 candidates: vec!["published".to_string()],
345 },
346 },
347 Clarification {
348 id: "notnull_change:users:status".to_string(),
349 severity: Severity::Fatal,
350 kind: ClarificationKind::NotNullChange {
351 table: "users".to_string(),
352 column: "status".to_string(),
353 },
354 },
355 Clarification {
356 id: "typecast:products:price".to_string(),
357 severity: Severity::Warning,
358 kind: ClarificationKind::TypeCast {
359 table: "products".to_string(),
360 column: "price".to_string(),
361 from: "text".to_string(),
362 to: "integer".to_string(),
363 },
364 },
365 Clarification {
366 id: "unknown_type:users:age".to_string(),
367 severity: Severity::Warning,
368 kind: ClarificationKind::UnknownType {
369 table: "users".to_string(),
370 column: "age".to_string(),
371 type_name: "intger".to_string(),
372 suggested: vec!["integer".to_string()],
373 },
374 },
375 ];
376
377 for clarification in clarifications {
378 let message = clarification_message(&clarification);
379 assert_nonempty_message(&message);
380 }
381 }
382
383 #[test]
385 fn unknown_type_message_maps_options_to_answers() {
386 let message = clarification_message(&Clarification {
387 id: "unknown_type:users:age".to_string(),
388 severity: Severity::Warning,
389 kind: ClarificationKind::UnknownType {
390 table: "users".to_string(),
391 column: "age".to_string(),
392 type_name: "intger".to_string(),
393 suggested: vec!["integer".to_string()],
394 },
395 });
396
397 assert_nonempty_message(&message);
398 assert!(matches!(
399 &message.options[0].action,
400 OptionAction::Fixed(Answer::UseType(type_name)) if type_name == "integer"
401 ));
402 assert!(matches!(
403 &message.options[1].action,
404 OptionAction::Fixed(Answer::KeepType)
405 ));
406 }
407}