Skip to main content

gaman_core/clarifier/
messages.rs

1use super::model::{Answer, Clarification, ClarificationKind, Severity};
2
3/// A single selectable option presented to the user.
4/// `Fixed` options resolve to an `Answer` immediately.
5/// `RequiresInput` options need the user to type a value; `make_answer` converts it to the
6/// correct `Answer` variant at message-build time.
7#[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/// The full, transport-agnostic representation of a clarification prompt.
34/// Engines use `description` as the question header and `options` as numbered choices.
35#[derive(Debug, Clone)]
36pub struct ClarificationMessage {
37    pub description: String,
38    pub options: Vec<ClarificationOption>,
39}
40
41/// Builds the display message for a clarification. Keep human-facing copy here so wording
42/// can be edited without touching clarification analysis or operation rewriting.
43pub 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    /// Verifies rename-column messages expose stable answer semantics without locking prompt copy.
274    #[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    /// Verifies not-null-add messages expose all required action shapes without locking prompt copy.
298    #[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    /// Verifies every clarification kind can be rendered as a non-empty message.
327    #[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    /// Verifies unknown-type messages expose suggested and custom-type answers without locking copy.
384    #[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}