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 ¬_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}