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