Skip to main content

gaman_core/clarifier/
mod.rs

1mod analyze;
2pub(crate) mod ids;
3pub mod messages;
4mod model;
5mod resolve;
6#[doc(hidden)]
7pub mod type_resolution;
8mod types;
9
10pub use messages::{
11    ClarificationMessage, ClarificationOption, OptionAction, clarification_message,
12};
13pub use model::{
14    Answer, Clarification, ClarificationKind, ClarifyError, ClarifyResult, Decision, PromptEngine,
15    PromptError, Severity,
16};
17#[doc(hidden)]
18pub use type_resolution::{TypeResolution, non_type_decisions, resolve_unknown_types};
19
20use crate::operations::Operation;
21use resolve::ClarifyPlan;
22
23pub struct Clarifier;
24
25impl Clarifier {
26    pub fn process(
27        &self,
28        ops: &[Operation],
29        decisions: &[Decision],
30    ) -> Result<ClarifyResult, ClarifyError> {
31        ClarifyPlan::new(ops).process(ops, decisions)
32    }
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38    use crate::states::{Column, EnumDef, Table};
39
40    fn col(name: &str, t: &str, nullable: bool) -> Column {
41        Column {
42            name: name.to_string(),
43            col_type: t.to_string(),
44            nullable,
45            default: None,
46            primary_key: false,
47            references: None,
48            check: None,
49            generated: None,
50        }
51    }
52
53    fn table(name: &str, columns: Vec<Column>) -> Table {
54        Table {
55            name: name.to_string(),
56            schema: None,
57            primary_key: None,
58            columns,
59            foreign_keys: vec![],
60            indexes: vec![],
61            constraints: vec![],
62            triggers: vec![],
63            options: Default::default(),
64        }
65    }
66
67    fn enum_def(values: &[&str]) -> EnumDef {
68        EnumDef {
69            name: "status".to_string(),
70            schema: None,
71            values: values.iter().map(|value| value.to_string()).collect(),
72            opaque: Default::default(),
73        }
74    }
75
76    fn decision(id: &str, answer: Answer) -> Decision {
77        Decision {
78            clarification_id: id.to_string(),
79            answer,
80        }
81    }
82
83    fn get_clarifications(ops: &[Operation]) -> Vec<Clarification> {
84        let d = Clarifier;
85        match d.process(ops, &[]).unwrap() {
86            ClarifyResult::NeedsInput(c) => c,
87            ClarifyResult::Resolved(_) => vec![],
88        }
89    }
90
91    #[test]
92    fn rename_column_candidates_are_deterministic_and_ranked() {
93        let ops = vec![
94            Operation::AddColumn {
95                table_name: "users".into(),
96                column: col("display_name", "text", true),
97            },
98            Operation::DropColumn {
99                table_name: "users".into(),
100                column: col("email", "varchar", true),
101                cascade: false,
102            },
103            Operation::AddColumn {
104                table_name: "users".into(),
105                column: col("email_address", "text", true),
106            },
107        ];
108
109        let clar = get_clarifications(&ops);
110        assert_eq!(clar.len(), 1);
111        assert_eq!(clar[0].id, "rename_col:users:email");
112        assert_eq!(clar[0].severity, Severity::Suggestion);
113        match &clar[0].kind {
114            ClarificationKind::RenameColumn {
115                table,
116                old,
117                candidates,
118            } => {
119                assert_eq!(table, "users");
120                assert_eq!(old, "email");
121                assert_eq!(candidates, &["email_address", "display_name"]);
122            }
123            _ => panic!("expected RenameColumn"),
124        }
125    }
126
127    #[test]
128    fn rename_column_incompatible_type_has_no_candidate() {
129        let ops = vec![
130            Operation::DropColumn {
131                table_name: "users".into(),
132                column: col("age", "text", true),
133                cascade: false,
134            },
135            Operation::AddColumn {
136                table_name: "users".into(),
137                column: col("score", "integer", true),
138            },
139        ];
140        assert!(get_clarifications(&ops).is_empty());
141    }
142
143    #[test]
144    fn rename_table_candidates_are_ranked_by_structure() {
145        let ops = vec![
146            Operation::DropTable {
147                table: table(
148                    "users",
149                    vec![col("id", "integer", false), col("email", "text", true)],
150                ),
151            },
152            Operation::CreateTable {
153                table: table("accounts", vec![col("id", "integer", false)]),
154            },
155            Operation::CreateTable {
156                table: table(
157                    "members",
158                    vec![col("id", "integer", false), col("email", "text", true)],
159                ),
160            },
161        ];
162
163        let clar = get_clarifications(&ops);
164        assert_eq!(clar.len(), 1);
165        match &clar[0].kind {
166            ClarificationKind::RenameTable { old, candidates } => {
167                assert_eq!(old, "users");
168                assert_eq!(candidates[0], "members");
169            }
170            _ => panic!("expected RenameTable"),
171        }
172    }
173
174    #[test]
175    fn enum_value_rename_decision_rewrites_drop_create() {
176        let ops = vec![
177            Operation::DropEnum {
178                enum_def: enum_def(&["draft", "live"]),
179            },
180            Operation::CreateEnum {
181                enum_def: enum_def(&["draft", "published", "archived"]),
182            },
183        ];
184        let d = Clarifier;
185        let resolved = match d
186            .process(
187                &ops,
188                &[decision(
189                    "rename_enum_value:status:live",
190                    Answer::RenameTo("published".to_string()),
191                )],
192            )
193            .unwrap()
194        {
195            ClarifyResult::Resolved(ops) => ops,
196            ClarifyResult::NeedsInput(c) => panic!("unexpected clarification: {c:?}"),
197        };
198        assert_eq!(resolved.len(), 2);
199        assert!(matches!(
200            &resolved[0],
201            Operation::RenameEnumValue {
202                enum_name,
203                old_value,
204                new_value,
205                ..
206            } if enum_name == "status" && old_value == "live" && new_value == "published"
207        ));
208        assert!(matches!(&resolved[1], Operation::AlterEnum { .. }));
209    }
210
211    #[test]
212    fn not_null_and_typecast_clarifications_are_detected() {
213        let ops = vec![
214            Operation::AddColumn {
215                table_name: "orders".into(),
216                column: col("reference_id", "integer", false),
217            },
218            Operation::AlterColumn {
219                table_name: "products".into(),
220                old: col("price", "text", true),
221                new: col("price", "integer", true),
222                cast_expr: None,
223            },
224        ];
225
226        let ids: Vec<String> = get_clarifications(&ops)
227            .into_iter()
228            .map(|clarification| clarification.id)
229            .collect();
230        assert_eq!(
231            ids,
232            ["notnull_add:orders:reference_id", "typecast:products:price"]
233        );
234    }
235
236    #[test]
237    fn decisions_rewrite_rename_and_backfill_operations() {
238        let ops = vec![
239            Operation::DropColumn {
240                table_name: "users".into(),
241                column: col("old_email", "varchar", true),
242                cascade: false,
243            },
244            Operation::AddColumn {
245                table_name: "users".into(),
246                column: col("new_email", "text", true),
247            },
248            Operation::AlterColumn {
249                table_name: "users".into(),
250                old: col("status", "text", true),
251                new: col("status", "text", false),
252                cast_expr: None,
253            },
254        ];
255        let decisions = vec![
256            decision(
257                "rename_col:users:old_email",
258                Answer::RenameTo("new_email".into()),
259            ),
260            decision(
261                "notnull_change:users:status",
262                Answer::NotNullDefault("'active'".into()),
263            ),
264        ];
265
266        let resolved = match Clarifier.process(&ops, &decisions).unwrap() {
267            ClarifyResult::Resolved(ops) => ops,
268            ClarifyResult::NeedsInput(c) => panic!("unexpected clarification: {c:?}"),
269        };
270
271        assert!(matches!(resolved[0], Operation::RenameColumn { .. }));
272        assert!(matches!(resolved[1], Operation::Statement { .. }));
273        assert!(matches!(resolved[2], Operation::AlterColumn { .. }));
274    }
275
276    #[test]
277    fn partial_decisions_return_only_still_pending_clarifications() {
278        let ops = vec![
279            Operation::DropColumn {
280                table_name: "t".into(),
281                column: col("a", "text", true),
282                cascade: false,
283            },
284            Operation::DropColumn {
285                table_name: "t".into(),
286                column: col("b", "text", true),
287                cascade: false,
288            },
289            Operation::AddColumn {
290                table_name: "t".into(),
291                column: col("alpha", "text", true),
292            },
293            Operation::AddColumn {
294                table_name: "t".into(),
295                column: col("beta", "text", true),
296            },
297        ];
298
299        let pending = match Clarifier
300            .process(
301                &ops,
302                &[decision("rename_col:t:a", Answer::RenameTo("alpha".into()))],
303            )
304            .unwrap()
305        {
306            ClarifyResult::NeedsInput(c) => c,
307            _ => panic!("expected NeedsInput"),
308        };
309
310        assert_eq!(pending.len(), 1);
311        match &pending[0].kind {
312            ClarificationKind::RenameColumn { candidates, .. } => {
313                assert!(!candidates.contains(&"alpha".to_string()));
314                assert!(candidates.contains(&"beta".to_string()));
315            }
316            _ => panic!("expected RenameColumn"),
317        }
318    }
319
320    #[test]
321    fn duplicate_decision_ids_fail() {
322        let ops = vec![
323            Operation::DropColumn {
324                table_name: "t".into(),
325                column: col("x", "text", true),
326                cascade: false,
327            },
328            Operation::AddColumn {
329                table_name: "t".into(),
330                column: col("y", "text", true),
331            },
332        ];
333        let err = Clarifier
334            .process(
335                &ops,
336                &[
337                    decision("rename_col:t:x", Answer::RenameNo),
338                    decision("rename_col:t:x", Answer::RenameNo),
339                ],
340            )
341            .unwrap_err();
342        assert_eq!(
343            err,
344            ClarifyError::DuplicateDecision("rename_col:t:x".to_string())
345        );
346    }
347
348    #[test]
349    fn conflicting_rename_targets_fail_before_rewrite() {
350        let ops = vec![
351            Operation::DropColumn {
352                table_name: "t".into(),
353                column: col("a", "text", true),
354                cascade: false,
355            },
356            Operation::DropColumn {
357                table_name: "t".into(),
358                column: col("b", "text", true),
359                cascade: false,
360            },
361            Operation::AddColumn {
362                table_name: "t".into(),
363                column: col("alpha", "text", true),
364            },
365        ];
366        let err = Clarifier
367            .process(
368                &ops,
369                &[
370                    decision("rename_col:t:a", Answer::RenameTo("alpha".into())),
371                    decision("rename_col:t:b", Answer::RenameTo("alpha".into())),
372                ],
373            )
374            .unwrap_err();
375        assert!(matches!(err, ClarifyError::ConflictingRenameTarget { .. }));
376    }
377
378    #[test]
379    fn empty_default_or_cast_input_fails() {
380        let not_null_ops = vec![Operation::AddColumn {
381            table_name: "orders".into(),
382            column: col("reference_id", "integer", false),
383        }];
384        let err = Clarifier
385            .process(
386                &not_null_ops,
387                &[decision(
388                    "notnull_add:orders:reference_id",
389                    Answer::NotNullDefault("  ".into()),
390                )],
391            )
392            .unwrap_err();
393        assert!(matches!(err, ClarifyError::EmptyInput { .. }));
394
395        let typecast_ops = vec![Operation::AlterColumn {
396            table_name: "products".into(),
397            old: col("price", "text", true),
398            new: col("price", "integer", true),
399            cast_expr: None,
400        }];
401        let err = Clarifier
402            .process(
403                &typecast_ops,
404                &[decision(
405                    "typecast:products:price",
406                    Answer::TypeCast("".into()),
407                )],
408            )
409            .unwrap_err();
410        assert!(matches!(err, ClarifyError::EmptyInput { .. }));
411    }
412
413    #[test]
414    fn ids_escape_names_containing_separators() {
415        let ops = vec![
416            Operation::DropColumn {
417                table_name: "tenant:users".into(),
418                column: col("email:old", "text", true),
419                cascade: false,
420            },
421            Operation::AddColumn {
422                table_name: "tenant:users".into(),
423                column: col("email:new", "text", true),
424            },
425        ];
426        let clar = get_clarifications(&ops);
427        assert_eq!(clar[0].id, "rename_col:tenant%3Ausers:email%3Aold");
428    }
429
430    #[test]
431    fn invalid_decision_errors_are_preserved() {
432        let ops = vec![
433            Operation::DropColumn {
434                table_name: "t".into(),
435                column: col("x", "text", true),
436                cascade: false,
437            },
438            Operation::AddColumn {
439                table_name: "t".into(),
440                column: col("y", "text", true),
441            },
442        ];
443
444        let unknown = Clarifier
445            .process(&[], &[decision("rename_col:t:x", Answer::RenameNo)])
446            .unwrap_err();
447        assert_eq!(
448            unknown,
449            ClarifyError::UnknownDecision("rename_col:t:x".into())
450        );
451
452        let invalid_candidate = Clarifier
453            .process(
454                &ops,
455                &[decision(
456                    "rename_col:t:x",
457                    Answer::RenameTo("nonexistent".into()),
458                )],
459            )
460            .unwrap_err();
461        assert!(matches!(
462            invalid_candidate,
463            ClarifyError::InvalidCandidate { .. }
464        ));
465
466        let invalid_answer = Clarifier
467            .process(
468                &ops,
469                &[decision(
470                    "rename_col:t:x",
471                    Answer::NotNullDefault("0".into()),
472                )],
473            )
474            .unwrap_err();
475        assert!(matches!(invalid_answer, ClarifyError::InvalidAnswer { .. }));
476    }
477}