Skip to main content

drizzle_cli/commands/
renames.rs

1//! Rename-or-create prompts for `drizzle generate` and `drizzle push`, ported
2//! from drizzle-kit's `resolver` (`cli/prompts.ts`).
3//!
4//! When the diff drops one entity and creates another of the same kind in
5//! the same scope, it cannot tell a rename from a drop plus a create. The
6//! CLI never guesses:
7//!
8//! - With a terminal on stdin, it asks, one question per created entity
9//!   that has deleted candidates, in drizzle-kit's order (schemas, enums,
10//!   tables, columns, indexes, views). The first choice creates; each other
11//!   choice renames from one candidate. A rename consumes its candidate.
12//! - Without one, it answers from `--hints` / `--hints-file` (drizzle-kit's
13//!   JSON hint format). An unhinted index, constraint or view is dropped
14//!   and created, which loses nothing. Unhinted schemas, enums, tables and
15//!   columns are listed and the command exits with code 2 without changing
16//!   anything.
17//!
18//! Hints are consulted in interactive runs too, so a hinted question is not
19//! asked. `push --force` only skips the data-loss confirmation, not these
20//! questions, as in drizzle-kit.
21
22use std::collections::HashSet;
23use std::io::IsTerminal;
24use std::path::PathBuf;
25
26use colored::Colorize;
27use drizzle_migrations::{DiffOptions, RenameAnswer, RenameKind, RenameQuestion, Snapshot};
28use serde_json::Value;
29
30use crate::error::CliError;
31
32/// `--hints` / `--hints-file`, shared by `generate` and `push`.
33#[derive(clap::Args, Debug, Clone, Default)]
34pub struct HintArgs {
35    /// Inline JSON array of hints answering rename-or-create questions
36    /// without prompting, e.g.
37    /// '[{"type":"rename","kind":"table","from":["public","users"],"to":["public","accounts"]}]'
38    #[arg(long, value_name = "JSON", conflicts_with = "hints_file")]
39    pub hints: Option<String>,
40
41    /// Path to a JSON file containing a hints array (same format as --hints)
42    #[arg(long = "hints-file", value_name = "PATH")]
43    pub hints_file: Option<PathBuf>,
44}
45
46/// One parsed hint (drizzle-kit's `Hint`).
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub enum Hint {
49    /// `{ "type": "rename", "kind", "from", "to" }`
50    Rename {
51        /// drizzle-kit kind name (`table`, `column`, ...).
52        kind: String,
53        /// Identifier tuple of the deleted entity.
54        from: Vec<String>,
55        /// Identifier tuple of the created entity.
56        to: Vec<String>,
57    },
58    /// `{ "type": "create", "kind", "entity" }`
59    Create {
60        /// drizzle-kit kind name.
61        kind: String,
62        /// Identifier tuple of the created entity.
63        entity: Vec<String>,
64    },
65    /// `{ "type": "confirm_data_loss", "kind", "entity" }`. Accepted so
66    /// drizzle-kit hint files parse; data-loss approval is `push --force`.
67    ConfirmDataLoss,
68}
69
70/// drizzle-kit's rename-or-create kinds and their identifier arity.
71const RENAME_KINDS: &[(&str, usize)] = &[
72    ("table", 2),
73    ("column", 3),
74    ("default", 3),
75    ("schema", 1),
76    ("enum", 2),
77    ("sequence", 2),
78    ("view", 2),
79    ("policy", 3),
80    ("role", 1),
81    ("privilege", 5),
82    ("check", 3),
83    ("index", 3),
84    ("unique", 3),
85    ("primary_key", 3),
86    ("foreign key", 3),
87];
88
89/// drizzle-kit's `confirm_data_loss` kinds and their identifier arity.
90const CONFIRM_KINDS: &[(&str, usize)] = &[
91    ("table", 2),
92    ("column", 3),
93    ("schema", 1),
94    ("view", 2),
95    ("primary_key", 3),
96    ("add_not_null", 3),
97    ("add_unique", 3),
98];
99
100impl HintArgs {
101    /// Reads and parses the hints (none when neither flag is set).
102    ///
103    /// # Errors
104    ///
105    /// Returns [`CliError::InvalidHints`] if the file cannot be read, the
106    /// text is not JSON, or an item does not have drizzle-kit's hint shape.
107    pub fn load(&self) -> Result<Vec<Hint>, CliError> {
108        let (source, text) = if let Some(path) = &self.hints_file {
109            let text = std::fs::read_to_string(path).map_err(|error| {
110                CliError::InvalidHints(format!(
111                    "Failed to read hints file '{}': {error}",
112                    path.display()
113                ))
114            })?;
115            ("file", text)
116        } else if let Some(text) = &self.hints {
117            ("inline", text.clone())
118        } else {
119            return Ok(Vec::new());
120        };
121        let json: Value = serde_json::from_str(&text).map_err(|error| {
122            CliError::InvalidHints(format!("Failed to parse hints JSON from {source}: {error}"))
123        })?;
124        parse_hints(&json)
125    }
126}
127
128/// Parses a JSON hints array.
129///
130/// # Errors
131///
132/// Returns [`CliError::InvalidHints`] naming the first malformed item.
133pub fn parse_hints(json: &Value) -> Result<Vec<Hint>, CliError> {
134    let invalid = |path: &str, message: &str| {
135        CliError::InvalidHints(format!("Invalid hint shape at {path}: {message}"))
136    };
137    let items = json
138        .as_array()
139        .ok_or_else(|| invalid("<root>", "expected an array"))?;
140    items
141        .iter()
142        .enumerate()
143        .map(|(index, item)| {
144            let at = |field: &str| format!("[{index}]{field}");
145            let object = item
146                .as_object()
147                .ok_or_else(|| invalid(&at(""), "expected an object"))?;
148            let text = |field: &str| -> Result<&str, CliError> {
149                object
150                    .get(field)
151                    .and_then(Value::as_str)
152                    .ok_or_else(|| invalid(&at(&format!(".{field}")), "expected a string"))
153            };
154            let kind = text("kind")?;
155            let hint_type = text("type")?;
156            let (kinds, fields): (&[(&str, usize)], &[&str]) = match hint_type {
157                "rename" => (RENAME_KINDS, &["type", "kind", "from", "to"]),
158                "create" => (RENAME_KINDS, &["type", "kind", "entity"]),
159                "confirm_data_loss" => (CONFIRM_KINDS, &["type", "kind", "entity"]),
160                _ => {
161                    return Err(invalid(
162                        &at(".type"),
163                        "expected \"rename\", \"create\" or \"confirm_data_loss\"",
164                    ));
165                }
166            };
167            if let Some(extra) = object.keys().find(|key| !fields.contains(&key.as_str())) {
168                return Err(invalid(&at(""), &format!("unrecognized key \"{extra}\"")));
169            }
170            let arity = kinds
171                .iter()
172                .find(|(name, _)| *name == kind)
173                .map(|(_, arity)| *arity)
174                .ok_or_else(|| {
175                    invalid(
176                        &at(".kind"),
177                        &format!("unknown kind \"{kind}\" for a {hint_type} hint"),
178                    )
179                })?;
180            let tuple = |field: &str| -> Result<Vec<String>, CliError> {
181                let values = object
182                    .get(field)
183                    .and_then(Value::as_array)
184                    .filter(|values| values.len() == arity)
185                    .ok_or_else(|| {
186                        invalid(
187                            &at(&format!(".{field}")),
188                            &format!("expected an array of {arity} strings"),
189                        )
190                    })?;
191                values
192                    .iter()
193                    .map(|value| {
194                        value
195                            .as_str()
196                            .map(str::to_string)
197                            .ok_or_else(|| invalid(&at(&format!(".{field}")), "expected strings"))
198                    })
199                    .collect()
200            };
201            Ok(match hint_type {
202                "rename" => Hint::Rename {
203                    kind: kind.to_string(),
204                    from: tuple("from")?,
205                    to: tuple("to")?,
206                },
207                "create" => Hint::Create {
208                    kind: kind.to_string(),
209                    entity: tuple("entity")?,
210                },
211                _ => {
212                    tuple("entity")?;
213                    Hint::ConfirmDataLoss
214                }
215            })
216        })
217        .collect()
218}
219
220/// Asks one rename-or-create question.
221pub trait RenamePrompt {
222    /// Returns the answer to `question`.
223    ///
224    /// # Errors
225    ///
226    /// Returns [`CliError`] when the prompt fails or is aborted.
227    fn ask(&mut self, question: &RenameQuestion) -> Result<RenameAnswer, CliError>;
228}
229
230/// The terminal prompt: drizzle-kit's `ResolveSelect`, drawn with
231/// `inquire::Select`.
232#[derive(Debug, Default)]
233pub struct TerminalPrompt;
234
235impl RenamePrompt for TerminalPrompt {
236    fn ask(&mut self, question: &RenameQuestion) -> Result<RenameAnswer, CliError> {
237        let choices = prompt_choices(question);
238        let selected = inquire::Select::new(&prompt_message(question), choices)
239            .without_filtering()
240            .without_help_message()
241            .with_page_size(10)
242            .raw_prompt()
243            .map_err(|error| match error {
244                inquire::InquireError::OperationCanceled
245                | inquire::InquireError::OperationInterrupted => {
246                    CliError::Aborted("Rename prompt aborted, so nothing was changed".to_string())
247                }
248                other => CliError::Other(format!("Rename prompt failed: {other}")),
249            })?;
250        Ok(selected.value.answer)
251    }
252}
253
254/// How unanswered questions are handled.
255pub enum Mode<'a> {
256    /// Ask each one.
257    Interactive(&'a mut dyn RenamePrompt),
258    /// Collect them as missing hints (drizzle-kit's non-interactive mode).
259    Hints,
260}
261
262/// Result of [`resolve_renames`].
263#[derive(Debug)]
264pub struct Resolution {
265    /// The options to diff with: the base options and one hint per decided
266    /// question.
267    pub options: DiffOptions,
268    /// Questions no hint answered (only in [`Mode::Hints`]).
269    pub missing: Vec<RenameQuestion>,
270}
271
272/// Decides every rename-or-create question between `prev` and `current`
273/// from `hints`, then from `mode`, the way drizzle-kit's resolver does.
274///
275/// # Errors
276///
277/// Returns [`CliError::InvalidHints`] if a rename hint targets a created
278/// entity but its `from` is not one of the deleted candidates, and
279/// [`CliError`] if listing the questions or a prompt fails.
280pub fn resolve_renames(
281    prev: &Snapshot,
282    current: &Snapshot,
283    base: DiffOptions,
284    hints: &[Hint],
285    mut mode: Mode<'_>,
286) -> Result<Resolution, CliError> {
287    let mut options = base;
288    let mut missing = Vec::new();
289    let mut asked = HashSet::new();
290    // drizzle-kit prints `--- all <kind> conflicts resolved ---` after each
291    // group (one kind in one scope) it decided something in.
292    let mut group = Group::default();
293
294    loop {
295        let questions = drizzle_migrations::rename_questions(prev, current, &options)
296            .map_err(|error| CliError::MigrationError(error.to_string()))?;
297        let Some(question) = questions.into_iter().next() else {
298            break;
299        };
300        group.enter(&question);
301
302        let key = (question.kind, entity_id(&question, &question.name));
303        if !asked.insert(key) {
304            return Err(CliError::Other(format!(
305                "internal error: the answer to {} `{}` did not apply",
306                question.kind, question.name
307            )));
308        }
309
310        let answer = if let Some(answer) = answer_from_hints(&question, hints)? {
311            answer
312        } else {
313            match &mut mode {
314                Mode::Interactive(prompt) => prompt.ask(&question)?,
315                Mode::Hints => {
316                    // Only a wrong answer about an entity that holds data
317                    // loses anything; the rest are dropped and created.
318                    // Assume "create" so later questions are listed as well.
319                    if question.kind.holds_data() {
320                        missing.push(question.clone());
321                    }
322                    question
323                        .answer(&mut options.renames, &RenameAnswer::Create)
324                        .map_err(|error| CliError::Other(error.to_string()))?;
325                    continue;
326                }
327            }
328        };
329        println!("{}", selection_line(&question, &answer));
330        group.decided = true;
331        question
332            .answer(&mut options.renames, &answer)
333            .map_err(|error| CliError::Other(error.to_string()))?;
334    }
335    group.finish();
336
337    Ok(Resolution { options, missing })
338}
339
340/// The group of questions being decided, for the end-of-group line.
341#[derive(Default)]
342struct Group {
343    scope: Option<(RenameKind, Option<String>, Option<String>)>,
344    decided: bool,
345}
346
347impl Group {
348    fn enter(&mut self, question: &RenameQuestion) {
349        let scope = (
350            question.kind,
351            question.schema.clone(),
352            question.table.clone(),
353        );
354        if self.scope.as_ref() != Some(&scope) {
355            self.finish();
356            self.scope = Some(scope);
357        }
358    }
359
360    fn finish(&mut self) {
361        if let Some((kind, _, _)) = self.scope.take()
362            && std::mem::take(&mut self.decided)
363        {
364            println!(
365                "{}",
366                format!("--- all {} conflicts resolved ---\n", humanize(kind)).bright_black()
367            );
368        }
369    }
370}
371
372/// Resolves renames for a command: interactive when stdin is a terminal,
373/// hints mode otherwise.
374///
375/// # Errors
376///
377/// Returns [`CliError::MissingHints`] (exit code 2) when hints mode leaves
378/// questions unanswered, and the errors of [`HintArgs::load`] and
379/// [`resolve_renames`].
380pub fn resolve_for_command(
381    prev: &Snapshot,
382    current: &Snapshot,
383    base: DiffOptions,
384    hint_args: &HintArgs,
385) -> Result<DiffOptions, CliError> {
386    let hints = hint_args.load()?;
387    if hints
388        .iter()
389        .any(|hint| matches!(hint, Hint::ConfirmDataLoss))
390    {
391        println!(
392            "{}",
393            crate::output::warn_line(
394                "confirm_data_loss hints are ignored; use `push --force` to approve data loss"
395            )
396        );
397    }
398    let mut prompt = TerminalPrompt;
399    let mode = if std::io::stdin().is_terminal() {
400        Mode::Interactive(&mut prompt)
401    } else {
402        Mode::Hints
403    };
404    let resolution = resolve_renames(prev, current, base, &hints, mode)?;
405    if !resolution.missing.is_empty() {
406        return Err(CliError::MissingHints(missing_hints_report(
407            &resolution.missing,
408        )));
409    }
410    Ok(resolution.options)
411}
412
413/// The answer the hints give to `question`, if any.
414fn answer_from_hints(
415    question: &RenameQuestion,
416    hints: &[Hint],
417) -> Result<Option<RenameAnswer>, CliError> {
418    let kind = question.kind.as_str();
419    let id = entity_id(question, &question.name);
420    for hint in hints {
421        if let Hint::Rename {
422            kind: hint_kind,
423            from,
424            to,
425        } = hint
426            && hint_kind == kind
427            && *to == id
428        {
429            let source = question
430                .candidates
431                .iter()
432                .find(|candidate| entity_id(question, candidate) == *from);
433            return match source {
434                Some(source) => Ok(Some(RenameAnswer::RenameFrom(source.clone()))),
435                None => Err(CliError::InvalidHints(format!(
436                    "rename hint's `from` {} doesn't match any deleted {}",
437                    json_tuple(from),
438                    humanize(question.kind)
439                ))),
440            };
441        }
442    }
443    let created = hints.iter().any(|hint| {
444        matches!(hint, Hint::Create { kind: hint_kind, entity } if hint_kind == kind && *entity == id)
445    });
446    Ok(created.then_some(RenameAnswer::Create))
447}
448
449/// drizzle-kit's identifier tuple for `name` in `question`'s scope:
450/// `[name]` for schemas, `[schema, name]` for enums, tables, and views,
451/// `[schema, table, name]` for columns, indexes, and constraints. SQLite and
452/// MySQL use the
453/// placeholder schema `public`, as drizzle-kit does.
454#[must_use]
455pub fn entity_id(question: &RenameQuestion, name: &str) -> Vec<String> {
456    let schema = question.schema.as_deref().unwrap_or("public").to_string();
457    match question.kind {
458        RenameKind::Schema => vec![name.to_string()],
459        RenameKind::Column
460        | RenameKind::Index
461        | RenameKind::Unique
462        | RenameKind::Check
463        | RenameKind::PrimaryKey
464        | RenameKind::ForeignKey => vec![
465            schema,
466            question.table.clone().unwrap_or_default(),
467            name.to_string(),
468        ],
469        _ => vec![schema, name.to_string()],
470    }
471}
472
473fn humanize(kind: RenameKind) -> String {
474    kind.as_str().replace('_', " ")
475}
476
477fn json_tuple(segments: &[String]) -> String {
478    let quoted: Vec<String> = segments
479        .iter()
480        .map(|segment| Value::String(segment.clone()).to_string())
481        .collect();
482    format!("[{}]", quoted.join(", "))
483}
484
485/// drizzle-kit's `keyFor`: `schema.` (when not `public`), then `table.`,
486/// then the name.
487fn key_for(question: &RenameQuestion, name: &str) -> String {
488    let mut key = String::new();
489    if let Some(schema) = question.schema.as_deref()
490        && schema != "public"
491    {
492        key.push_str(schema);
493        key.push('.');
494    }
495    if let Some(table) = question.table.as_deref() {
496        key.push_str(table);
497        key.push('.');
498    }
499    key.push_str(name);
500    key
501}
502
503/// The question line: `Is <key> <kind> created or renamed from another <kind>?`
504#[must_use]
505pub fn prompt_message(question: &RenameQuestion) -> String {
506    let kind = humanize(question.kind);
507    format!(
508        "Is {} {kind} created or renamed from another {kind}?",
509        key_for(question, &question.name).bold().blue()
510    )
511}
512
513/// One choice of the prompt; its `Display` is the rendered line.
514#[derive(Clone, Debug, PartialEq, Eq)]
515pub struct Choice {
516    /// What picking this choice answers.
517    pub answer: RenameAnswer,
518    label: String,
519}
520
521impl std::fmt::Display for Choice {
522    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
523        f.write_str(&self.label)
524    }
525}
526
527/// The prompt's choices, as drizzle-kit renders them: first
528/// `+ <name>  create <kind>`, then `~ <old> › <new>  rename <kind>` for each
529/// candidate, padded to a common width.
530#[must_use]
531pub fn prompt_choices(question: &RenameQuestion) -> Vec<Choice> {
532    let kind = humanize(question.kind);
533    let key = key_for(question, &question.name);
534    let titles: Vec<String> = question
535        .candidates
536        .iter()
537        .map(|candidate| format!("{} › {key}", key_for(question, candidate)))
538        .collect();
539    let width = titles
540        .iter()
541        .map(|title| title.chars().count())
542        .max()
543        .unwrap_or(0);
544
545    let mut choices = vec![Choice {
546        answer: RenameAnswer::Create,
547        label: format!(
548            "{} {key:<width$} {}",
549            "+".green(),
550            format!("create {kind}").bright_black()
551        ),
552    }];
553    for (candidate, title) in question.candidates.iter().zip(titles) {
554        let pad = width.saturating_sub(title.chars().count());
555        choices.push(Choice {
556            answer: RenameAnswer::RenameFrom(candidate.clone()),
557            label: format!(
558                "{} {title}{} {}",
559                "~".yellow(),
560                " ".repeat(pad),
561                format!("rename {kind}").bright_black()
562            ),
563        });
564    }
565    choices
566}
567
568/// The line printed after a decision (drizzle-kit's `applySelection` log).
569fn selection_line(question: &RenameQuestion, answer: &RenameAnswer) -> String {
570    let kind = humanize(question.kind);
571    match answer {
572        RenameAnswer::Create => format!(
573            "{} {} {}",
574            "+".green(),
575            question.name,
576            format!("{kind} will be created").bright_black()
577        ),
578        RenameAnswer::RenameFrom(from) => format!(
579            "{} {} › {} {}",
580            "~".yellow(),
581            key_for(question, from),
582            key_for(question, &question.name),
583            format!("{kind} will be renamed/moved").bright_black()
584        ),
585    }
586}
587
588/// drizzle-kit's missing-hints report (`missing-hints-report.ts`), plus the
589/// deleted candidates of each question.
590#[must_use]
591pub fn missing_hints_report(missing: &[RenameQuestion]) -> String {
592    let mut lines = vec![
593        format!("missing_hints: {} unresolved decisions", missing.len())
594            .yellow()
595            .bold()
596            .to_string(),
597    ];
598    for (index, question) in missing.iter().enumerate() {
599        let kind = question.kind.as_str();
600        let id = entity_id(question, &question.name);
601        let placeholder: Vec<String> = match id.len() {
602            1 => vec!["<old_name>".into()],
603            2 => vec!["<schema>".into(), "<old_name>".into()],
604            _ => vec!["<schema>".into(), "<table>".into(), "<old_name>".into()],
605        };
606        let candidates: Vec<String> = question
607            .candidates
608            .iter()
609            .map(|candidate| json_tuple(&entity_id(question, candidate)))
610            .collect();
611        lines.push(String::new());
612        lines.push(format!(
613            "{}. Rename or create  {}  {}  {}",
614            index + 1,
615            "—".bright_black(),
616            humanize(question.kind),
617            id.join(".").bold().cyan()
618        ));
619        lines.push(format!(
620            "   {} {}",
621            "Deleted candidates:".bright_black(),
622            candidates.join(", ")
623        ));
624        lines.push("   Add to --hints:".to_string());
625        lines.push(format!(
626            "     {{ \"type\": \"rename\", \"kind\": \"{kind}\", \"from\": {}, \"to\": {} }}",
627            json_tuple(&placeholder),
628            json_tuple(&id)
629        ));
630        lines.push("     OR".to_string());
631        lines.push(format!(
632            "     {{ \"type\": \"create\", \"kind\": \"{kind}\", \"entity\": {} }}",
633            json_tuple(&id)
634        ));
635    }
636    lines.push(String::new());
637    lines.push(
638        "Re-run with --hints '<json-array>' or --hints-file <path>. Exit code 2."
639            .bright_black()
640            .to_string(),
641    );
642    lines.join("\n") + "\n"
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use drizzle_migrations::sqlite::SQLiteSnapshot;
649    use drizzle_migrations::sqlite::ddl::{Column, SqliteEntity, Table};
650
651    fn plain(text: &str) -> String {
652        let mut out = String::new();
653        let mut chars = text.chars();
654        while let Some(c) = chars.next() {
655            if c == '\u{1b}' {
656                for c in chars.by_ref() {
657                    if c == 'm' {
658                        break;
659                    }
660                }
661            } else {
662                out.push(c);
663            }
664        }
665        out
666    }
667
668    fn sqlite(tables: &[(&'static str, &[&'static str])]) -> Snapshot {
669        let mut snapshot = SQLiteSnapshot::new();
670        for (table, columns) in tables {
671            snapshot.add_entity(SqliteEntity::Table(Table::new(*table)));
672            for column in *columns {
673                snapshot.add_entity(SqliteEntity::Column(
674                    Column::new(*table, *column, "text").not_null(),
675                ));
676            }
677        }
678        Snapshot::Sqlite(snapshot)
679    }
680
681    fn question(
682        kind: RenameKind,
683        schema: Option<&str>,
684        table: Option<&str>,
685        name: &str,
686        candidates: &[&str],
687    ) -> RenameQuestion {
688        RenameQuestion {
689            kind,
690            schema: schema.map(str::to_string),
691            table: table.map(str::to_string),
692            name: name.to_string(),
693            candidates: candidates.iter().map(|c| (*c).to_string()).collect(),
694        }
695    }
696
697    /// Answers from a script and records what it was asked.
698    struct Scripted {
699        answers: Vec<usize>,
700        asked: Vec<(String, Vec<String>)>,
701    }
702
703    impl RenamePrompt for Scripted {
704        fn ask(&mut self, question: &RenameQuestion) -> Result<RenameAnswer, CliError> {
705            let choices = prompt_choices(question);
706            self.asked.push((
707                plain(&prompt_message(question)),
708                choices.iter().map(|c| plain(&c.to_string())).collect(),
709            ));
710            let pick = self.answers.remove(0);
711            Ok(choices[pick].answer.clone())
712        }
713    }
714
715    #[test]
716    fn renders_like_drizzle_kit() {
717        let q = question(
718            RenameKind::Table,
719            None,
720            None,
721            "accounts",
722            &["users", "people"],
723        );
724        assert_eq!(
725            plain(&prompt_message(&q)),
726            "Is accounts table created or renamed from another table?"
727        );
728        let choices: Vec<String> = prompt_choices(&q)
729            .iter()
730            .map(|c| plain(&c.to_string()))
731            .collect();
732        assert_eq!(
733            choices,
734            [
735                "+ accounts          create table",
736                "~ users › accounts  rename table",
737                "~ people › accounts rename table",
738            ]
739        );
740
741        let q = question(
742            RenameKind::Column,
743            Some("app"),
744            Some("users"),
745            "full_name",
746            &["name"],
747        );
748        assert_eq!(
749            plain(&prompt_message(&q)),
750            "Is app.users.full_name column created or renamed from another column?"
751        );
752        assert_eq!(
753            plain(&prompt_choices(&q)[1].to_string()),
754            "~ app.users.name › app.users.full_name rename column"
755        );
756        assert_eq!(
757            entity_id(&q, "name"),
758            ["app", "users", "name"].map(String::from)
759        );
760        assert_eq!(
761            entity_id(&question(RenameKind::Table, None, None, "t", &["x"]), "t"),
762            ["public", "t"].map(String::from)
763        );
764    }
765
766    #[test]
767    fn interactive_answers_apply_in_order_and_consume_candidates() {
768        let prev = sqlite(&[("a", &["id"]), ("b", &["id", "old"])]);
769        let cur = sqlite(&[("c", &["id"]), ("d", &["id", "new"])]);
770        // c: rename from b (choice 2); d: only `a` is left, create (choice 0).
771        // c (formerly b) only lost a column, so no column question follows.
772        let mut prompt = Scripted {
773            answers: vec![2, 0],
774            asked: Vec::new(),
775        };
776        let resolution = resolve_renames(
777            &prev,
778            &cur,
779            DiffOptions::new(),
780            &[],
781            Mode::Interactive(&mut prompt),
782        )
783        .unwrap();
784
785        assert_eq!(prompt.asked.len(), 2);
786        assert_eq!(
787            prompt.asked[0].1,
788            [
789                "+ c     create table",
790                "~ a › c rename table",
791                "~ b › c rename table",
792            ]
793        );
794        assert_eq!(
795            prompt.asked[1].1,
796            ["+ d     create table", "~ a › d rename table"],
797            "b was consumed by the first answer"
798        );
799        assert!(resolution.missing.is_empty());
800        let plan = drizzle_migrations::diff_with(&prev, &cur, &resolution.options).unwrap();
801        assert!(
802            plan.statements
803                .contains(&"ALTER TABLE `b` RENAME TO `c`;".to_string())
804        );
805        assert!(
806            plan.statements
807                .iter()
808                .any(|s| s.starts_with("CREATE TABLE `d`"))
809        );
810        assert!(
811            plan.statements
812                .iter()
813                .any(|s| s.starts_with("DROP TABLE `a`"))
814        );
815    }
816
817    #[test]
818    fn column_questions_use_the_renamed_table() {
819        let prev = sqlite(&[("users", &["id", "name"])]);
820        let cur = sqlite(&[("accounts", &["id", "full_name"])]);
821        let mut prompt = Scripted {
822            answers: vec![1, 1],
823            asked: Vec::new(),
824        };
825        let resolution = resolve_renames(
826            &prev,
827            &cur,
828            DiffOptions::new(),
829            &[],
830            Mode::Interactive(&mut prompt),
831        )
832        .unwrap();
833        assert_eq!(
834            prompt.asked[1].0,
835            "Is accounts.full_name column created or renamed from another column?"
836        );
837        let plan = drizzle_migrations::diff_with(&prev, &cur, &resolution.options).unwrap();
838        assert_eq!(
839            plan.statements,
840            [
841                "ALTER TABLE `users` RENAME TO `accounts`;",
842                "ALTER TABLE `accounts` RENAME COLUMN `name` TO `full_name`;",
843            ]
844        );
845    }
846
847    #[test]
848    fn hints_answer_without_prompting_and_unanswered_are_missing() {
849        let prev = sqlite(&[("users", &["id", "name"]), ("logs", &["id", "body"])]);
850        let cur = sqlite(&[("accounts", &["id", "name"]), ("logs", &["id", "text"])]);
851        let hints = parse_hints(&serde_json::json!([
852            {"type": "rename", "kind": "table", "from": ["public", "users"], "to": ["public", "accounts"]},
853            {"type": "rename", "kind": "table", "from": ["public", "x"], "to": ["public", "unrelated"]},
854        ]))
855        .unwrap();
856
857        let resolution =
858            resolve_renames(&prev, &cur, DiffOptions::new(), &hints, Mode::Hints).unwrap();
859        assert_eq!(
860            resolution.missing,
861            [question(
862                RenameKind::Column,
863                None,
864                Some("logs"),
865                "text",
866                &["body"]
867            )]
868        );
869        let report = plain(&missing_hints_report(&resolution.missing));
870        assert!(
871            report.starts_with("missing_hints: 1 unresolved decisions\n"),
872            "{report}"
873        );
874        assert!(report.contains("1. Rename or create  —  column  public.logs.text"));
875        assert!(report.contains("Deleted candidates: [\"public\", \"logs\", \"body\"]"));
876        assert!(report.contains(
877            r#"{ "type": "rename", "kind": "column", "from": ["<schema>", "<table>", "<old_name>"], "to": ["public", "logs", "text"] }"#
878        ));
879        assert!(report.contains(
880            r#"{ "type": "create", "kind": "column", "entity": ["public", "logs", "text"] }"#
881        ));
882
883        // The same hints in an interactive run skip the hinted question.
884        let mut prompt = Scripted {
885            answers: vec![0],
886            asked: Vec::new(),
887        };
888        resolve_renames(
889            &prev,
890            &cur,
891            DiffOptions::new(),
892            &hints,
893            Mode::Interactive(&mut prompt),
894        )
895        .unwrap();
896        assert_eq!(prompt.asked.len(), 1);
897        assert!(prompt.asked[0].0.starts_with("Is logs.text column"));
898    }
899
900    #[test]
901    fn create_hints_keep_drop_and_create() {
902        let prev = sqlite(&[("users", &["id"])]);
903        let cur = sqlite(&[("accounts", &["id"])]);
904        let hints = parse_hints(&serde_json::json!([
905            {"type": "create", "kind": "table", "entity": ["public", "accounts"]},
906        ]))
907        .unwrap();
908        let resolution =
909            resolve_renames(&prev, &cur, DiffOptions::new(), &hints, Mode::Hints).unwrap();
910        assert!(resolution.missing.is_empty());
911        let plan = drizzle_migrations::diff_with(&prev, &cur, &resolution.options).unwrap();
912        assert!(!plan.statements.iter().any(|s| s.contains("RENAME")));
913    }
914
915    #[test]
916    fn rename_hint_from_a_non_candidate_is_rejected() {
917        let prev = sqlite(&[("users", &["id"])]);
918        let cur = sqlite(&[("accounts", &["id"])]);
919        let hints = parse_hints(&serde_json::json!([
920            {"type": "rename", "kind": "table", "from": ["public", "people"], "to": ["public", "accounts"]},
921        ]))
922        .unwrap();
923        let error =
924            resolve_renames(&prev, &cur, DiffOptions::new(), &hints, Mode::Hints).unwrap_err();
925        assert_eq!(
926            error.to_string(),
927            "Invalid hints: rename hint's `from` [\"public\", \"people\"] doesn't match any deleted table"
928        );
929    }
930
931    #[test]
932    fn hint_shapes_are_validated() {
933        for (json, message) in [
934            (serde_json::json!({}), "at <root>: expected an array"),
935            (
936                serde_json::json!([{"type": "rename", "kind": "table", "from": ["users"], "to": ["public", "a"]}]),
937                "at [0].from: expected an array of 2 strings",
938            ),
939            (
940                serde_json::json!([{"type": "create", "kind": "tables", "entity": ["public", "a"]}]),
941                "at [0].kind: unknown kind \"tables\"",
942            ),
943            (
944                serde_json::json!([{"type": "create", "kind": "table", "entity": ["public", "a"], "x": 1}]),
945                "unrecognized key \"x\"",
946            ),
947        ] {
948            let error = parse_hints(&json).unwrap_err().to_string();
949            assert!(error.contains(message), "{error}");
950        }
951        assert_eq!(
952            parse_hints(&serde_json::json!([
953                {"type": "confirm_data_loss", "kind": "table", "entity": ["public", "a"]},
954                {"type": "rename", "kind": "foreign key", "from": ["public", "t", "a"], "to": ["public", "t", "b"]},
955            ]))
956            .unwrap()
957            .len(),
958            2,
959            "kinds drizzle-kit knows parse even when this CLI never asks about them"
960        );
961    }
962}