feat: ADR-0035 Amendment 1 follow-up — enrich replay errors + close message gaps

- F2-broad: replay failures now render with real schema context instead of
  a contextless friendly_message(). Extract App::build_translate_context into
  the shared App::translate_context_for(command, facts, verbosity); run_replay
  enriches via enrich_dsl_failure + that builder. ctx_* fallbacks degrade to
  neutral prose so the rare non-replay contextless callsites can't leak raw
  {name} either. (SQL INSERT/UPDATE values aren't retained — ADR-0033 verbatim
  — so those show real table/column + neutral "that value".)
- Gap C: SQL ALTER … ADD FOREIGN KEY on a missing child column refuses with an
  SQL-appropriate "add it first", not the DSL-only --create-fk flag.
- Gap B: dropping a single-column-UNIQUE column refuses with a pointer to
  `drop constraint unique from T.col` (was an opaque generic refusal).
- Gap D: 4e drop/rename CHECK-guard + 4f change-type FK-guard refusals reworded
  to explain why; static_refusal reasons left as-is.

Tests: +4, 3 strengthened. 1926 pass / 0 fail / 0 skip; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 18:30:31 +00:00
parent cb8ff8a7c2
commit f8a91f41c9
8 changed files with 328 additions and 41 deletions
+64 -8
View File
@@ -182,11 +182,15 @@ fn drop_column_referenced_by_a_table_check_is_refused() {
let r = rt();
make_t_with_check(&db, &r);
// `a` is referenced by the CHECK `a < b` → refused (both surfaces;
// here via the simple `drop column`).
// here via the simple `drop column`). The refusal explains why
// (ADR-0035 Amendment 1, gap D).
let msg = r
.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.expect_err("dropping a CHECK-referenced column is refused")
.friendly_message();
assert!(
r.block_on(db.drop_column("T".to_string(), "a".to_string(), false, None))
.is_err(),
"dropping a CHECK-referenced column is refused"
msg.contains("CHECK constraint refers to"),
"the refusal explains why; got: {msg}"
);
// `c` is not referenced → the drop succeeds.
r.block_on(db.drop_column("T".to_string(), "c".to_string(), false, None))
@@ -220,6 +224,54 @@ fn make_t_with_composite_unique(db: &Database, r: &tokio::runtime::Runtime) {
.expect("add composite UNIQUE (a, b)");
}
/// `T (id int pk, email text UNIQUE, note text)` — a single-column UNIQUE
/// (ADR-0029, rides on the column `unique` flag, not `unique_constraints`).
fn make_t_with_single_unique(db: &Database, r: &tokio::runtime::Runtime) {
let mut email = ColumnSpec::new("email", Type::Text);
email.unique = true;
r.block_on(db.sql_create_table(
"T".to_string(),
vec![
ColumnSpec::new("id", Type::Int),
email,
ColumnSpec::new("note", Type::Text),
],
vec!["id".to_string()],
vec![],
vec![],
vec![],
false,
Some("create table T (id int primary key, email text unique, note text)".to_string()),
))
.expect("create T with a single-column UNIQUE");
}
#[test]
fn drop_column_with_a_single_column_unique_is_refused_with_actionable_message() {
let (_p, db, _d) = open();
let r = rt();
make_t_with_single_unique(&db, &r);
// `email` carries a single-column UNIQUE → the engine refuses the drop.
// Surface a friendly, actionable refusal pointing at the column-level
// drop-constraint (ADR-0029), not the engine's opaque generic refusal
// (ADR-0035 Amendment 1, gap B).
let err = r
.block_on(db.drop_column("T".to_string(), "email".to_string(), false, None))
.expect_err("dropping a single-column-UNIQUE column is refused");
let msg = err.friendly_message();
assert!(
msg.to_lowercase().contains("unique"),
"names the constraint kind; got: {msg}"
);
assert!(
msg.contains("drop constraint unique from T.email"),
"points at the column-level drop-constraint; got: {msg}"
);
// `note` has no constraint → the drop succeeds.
r.block_on(db.drop_column("T".to_string(), "note".to_string(), false, None))
.expect("dropping an unconstrained column succeeds");
}
#[test]
fn drop_column_covered_by_a_composite_unique_is_refused_with_the_derived_name() {
let (_p, db, _d) = open();
@@ -249,11 +301,15 @@ fn rename_column_referenced_by_a_table_check_is_refused() {
let r = rt();
make_t_with_check(&db, &r);
// `a` is referenced → refused (without this guard, a native rename
// would silently drift the CHECK metadata and break rebuild).
// would silently drift the CHECK metadata and break rebuild). The
// refusal explains why (ADR-0035 Amendment 1, gap D).
let msg = r
.block_on(db.rename_column("T".to_string(), "a".to_string(), "z".to_string(), None))
.expect_err("renaming a CHECK-referenced column is refused")
.friendly_message();
assert!(
r.block_on(db.rename_column("T".to_string(), "a".to_string(), "z".to_string(), None))
.is_err(),
"renaming a CHECK-referenced column is refused"
msg.contains("CHECK constraint refers to"),
"the refusal explains why; got: {msg}"
);
// `c` is not referenced → rename succeeds.
r.block_on(db.rename_column("T".to_string(), "c".to_string(), "note".to_string(), None))