//! `project.yaml` writer (hand-rolled, ADR-0015 §3) and //! reader (`serde_norway`, ADR-0015 §7). //! //! The schema YAML uses a small, fixed set of structures — //! tables, columns, relationships — and the values it carries //! are all known-safe (identifiers from the DSL, types from //! the fixed `Type` enum, action names from `ReferentialAction`). //! Hand-rolling the writer avoids pulling a YAML serializer //! dep just for the write path; the read path uses //! `serde_norway` because we need to handle whatever the user //! (or a future migrator, or a hand-edit) puts in there. // // `pub(crate)` items in this private submodule are // re-exported from `persistence::mod.rs`; that path is what // the db worker uses. Clippy's `redundant_pub_crate` lint // flags this pattern, but it's load-bearing here. #![allow(clippy::redundant_pub_crate)] use std::fmt::Write as _; use serde::Deserialize; use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; use crate::mode::Mode; use super::{ ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema, }; /// Serialize a `SchemaSnapshot` to a `project.yaml` body. #[must_use] pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String { let mut out = String::new(); let _ = writeln!(out, "version: 1"); let _ = writeln!(out, "project:"); let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at)); // ADR-0015 mode-restore amendment (issue #14): the input mode // lives alongside `created_at` as project-level metadata, not // schema. `rebuild` ignores it; restore-on-open reads it. let _ = writeln!(out, " mode: {}", schema.mode.keyword()); if schema.tables.is_empty() { let _ = writeln!(out, "tables: []"); } else { let _ = writeln!(out, "tables:"); for table in &schema.tables { write_table(&mut out, table); } } if schema.relationships.is_empty() { let _ = writeln!(out, "relationships: []"); } else { let _ = writeln!(out, "relationships:"); for rel in &schema.relationships { write_relationship(&mut out, rel); } } if schema.indexes.is_empty() { let _ = writeln!(out, "indexes: []"); } else { let _ = writeln!(out, "indexes:"); for index in &schema.indexes { write_index(&mut out, index); } } out } fn write_index(out: &mut String, index: &IndexSchema) { let _ = writeln!(out, " - name: {}", quote_if_needed(&index.name)); let _ = writeln!(out, " table: {}", quote_if_needed(&index.table)); write!(out, " columns: [").unwrap(); for (i, col) in index.columns.iter().enumerate() { if i > 0 { out.push_str(", "); } out.push_str("e_if_needed(col)); } let _ = writeln!(out, "]"); // Emit `unique` only when true (ADR-0035 §4d), matching the // column-`unique` convention — keeps pre-unique-index project files // byte-stable on a no-op round-trip. if index.unique { let _ = writeln!(out, " unique: true"); } } fn write_table(out: &mut String, table: &TableSchema) { let _ = writeln!(out, " - name: {}", quote_if_needed(&table.name)); write!(out, " primary_key: [").unwrap(); for (i, key) in table.primary_key.iter().enumerate() { if i > 0 { out.push_str(", "); } out.push_str("e_if_needed(key)); } let _ = writeln!(out, "]"); let _ = writeln!(out, " columns:"); for col in &table.columns { write_column(out, col); } // Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) — // emitted only when present so unconstrained tables stay compact. if !table.unique_constraints.is_empty() { let _ = writeln!(out, " unique_constraints:"); for cols in &table.unique_constraints { write!(out, " - [").unwrap(); for (i, c) in cols.iter().enumerate() { if i > 0 { out.push_str(", "); } out.push_str("e_if_needed(c)); } let _ = writeln!(out, "]"); } } // Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3) — // double-quoted (an expression like `a < b` is not a bare scalar) // and emitted only when present. An unnamed CHECK is a bare string // (back-compatible); a named CHECK (ADR-0035 §4g) is an `{expr, // name}` mapping so the name round-trips through a rebuild. if !table.check_constraints.is_empty() { let _ = writeln!(out, " check_constraints:"); for check in &table.check_constraints { match &check.name { None => { let _ = writeln!(out, " - {}", yaml_string(&check.expr)); } Some(name) => { let _ = writeln!(out, " - expr: {}", yaml_string(&check.expr)); let _ = writeln!(out, " name: {}", yaml_string(name)); } } } } } /// Always render `s` as a double-quoted YAML string — used /// for a column's `default` SQL literal, which must round-trip /// as a string even when it looks numeric (ADR-0029). fn yaml_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), _ => out.push(c), } } out.push('"'); out } fn write_column(out: &mut String, col: &ColumnSchema) { let mut line = format!( " - {{ name: {}, type: {}", quote_if_needed(&col.name), col.user_type.keyword(), ); // ADR-0018 / ADR-0029 constraint flags — emitted only when // set, so an unconstrained column stays a compact two-field // entry and older readers stay forward-compatible. if col.unique { line.push_str(", unique: true"); } if col.not_null { line.push_str(", not_null: true"); } if let Some(default) = &col.default { line.push_str(", default: "); line.push_str(&yaml_string(default)); } if let Some(check) = &col.check { line.push_str(", check: "); line.push_str(&yaml_string(check)); } line.push_str(" }"); let _ = writeln!(out, "{line}"); } fn write_relationship(out: &mut String, rel: &RelationshipSchema) { let _ = writeln!(out, " - name: {}", quote_if_needed(&rel.name)); let _ = writeln!( out, " parent: {{ table: {}, columns: [{}] }}", quote_if_needed(&rel.parent_table), write_col_list(&rel.parent_columns), ); let _ = writeln!( out, " child: {{ table: {}, columns: [{}] }}", quote_if_needed(&rel.child_table), write_col_list(&rel.child_columns), ); let _ = writeln!(out, " on_delete: {}", action_keyword(rel.on_delete)); let _ = writeln!(out, " on_update: {}", action_keyword(rel.on_update)); } /// Format a column list for an inline yaml flow sequence — `a, b` /// (the caller wraps in `[…]`), each element quoted if needed. /// Matches the `primary_key: [...]` / index `columns: [...]` house /// style (ADR-0043 D5). One element for a single-column endpoint. fn write_col_list(cols: &[String]) -> String { cols.iter() .map(|c| quote_if_needed(c)) .collect::>() .join(", ") } const fn action_keyword(action: ReferentialAction) -> &'static str { match action { ReferentialAction::NoAction => "no_action", ReferentialAction::Restrict => "restrict", ReferentialAction::SetNull => "set_null", ReferentialAction::Cascade => "cascade", } } /// Quote a string for safe inclusion as a YAML scalar. /// /// We're conservative: anything not made of safe characters /// (alphanumerics, `_`, `-`, `:` for ISO timestamps, `.`) /// gets double-quoted with `"` and `\` escaped. Common /// identifiers from the DSL (which restricts to alnum + `_`) /// pass through unquoted, which keeps the YAML pleasantly /// readable. fn quote_if_needed(s: &str) -> String { if needs_quoting(s) { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), _ => out.push(c), } } out.push('"'); out } else { s.to_string() } } fn needs_quoting(s: &str) -> bool { if s.is_empty() { return true; } // YAML reserves several leading characters and the empty // string. Be defensive on anything outside the safe set. let first = s.chars().next().unwrap(); if !is_safe_yaml_char(first) || first == '-' { return true; } // Scalar text that looks like a YAML keyword needs quoting // even if every character is safe. if matches!( s, "true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off" ) { return true; } s.chars().any(|c| !is_safe_yaml_char(c)) } const fn is_safe_yaml_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':') } /// Parse a `project.yaml` body into a `SchemaSnapshot`. /// /// The wire types below mirror the format `serialize_schema` /// emits. Anything outside that shape produces a structured /// error — callers (the rebuild path) translate those into a /// fatal banner per ADR-0015 §8. pub(crate) fn parse_schema(body: &str) -> Result { let raw: RawProject = serde_norway::from_str(body).map_err(|e| YamlError::Syntax(e.to_string()))?; if raw.version != 1 { return Err(YamlError::UnsupportedVersion(raw.version)); } let mut tables: Vec = Vec::with_capacity(raw.tables.len()); for t in raw.tables { let mut columns: Vec = Vec::with_capacity(t.columns.len()); for c in t.columns { let user_type = c .user_type .parse::() .map_err(|_| YamlError::UnknownType { table: t.name.clone(), column: c.name.clone(), raw: c.user_type.clone(), })?; columns.push(ColumnSchema { name: c.name, user_type, unique: c.unique, not_null: c.not_null, default: c.default, check: c.check, }); } tables.push(TableSchema { name: t.name, primary_key: t.primary_key, columns, unique_constraints: t.unique_constraints, check_constraints: t .check_constraints .into_iter() .map(TableCheck::from) .collect(), }); } let mut relationships: Vec = Vec::with_capacity(raw.relationships.len()); for r in raw.relationships { let on_delete = parse_action(&r.on_delete) .ok_or_else(|| YamlError::UnknownAction(r.on_delete.clone()))?; let on_update = parse_action(&r.on_update) .ok_or_else(|| YamlError::UnknownAction(r.on_update.clone()))?; relationships.push(RelationshipSchema { name: r.name, parent_table: r.parent.table, parent_columns: r.parent.columns, child_table: r.child.table, child_columns: r.child.columns, on_delete, on_update, }); } let indexes: Vec = raw .indexes .into_iter() .map(|i| IndexSchema { name: i.name, table: i.table, columns: i.columns, unique: i.unique, }) .collect(); Ok(SchemaSnapshot { created_at: raw.project.created_at, mode: raw .project .mode .as_deref() .and_then(Mode::from_keyword) .unwrap_or_default(), tables, relationships, indexes, }) } /// Read just the stored input mode from a `project.yaml` body, /// for restore-on-open (ADR-0015 mode-restore amendment, issue /// #14). Returns `None` when the file has no `mode:` field (a /// pre-#14 project, or a hand-written one) — distinct from an /// explicit `mode: simple` — so the caller can tell "no stored /// preference" from a deliberate choice. An unrecognised value /// is also `None` (fall back to the default rather than reject /// the whole file over a UI hint). Tolerant of an otherwise /// unparseable body for the same reason. #[must_use] pub(super) fn parse_stored_mode(body: &str) -> Option { let raw: RawProject = serde_norway::from_str(body).ok()?; raw.project.mode.as_deref().and_then(Mode::from_keyword) } #[derive(Debug)] pub(crate) enum YamlError { Syntax(String), UnsupportedVersion(u32), UnknownType { table: String, column: String, raw: String, }, UnknownAction(String), } impl std::fmt::Display for YamlError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)), Self::UnsupportedVersion(v) => f.write_str(&crate::t!( "persistence.yaml.unsupported_version", version = v, )), Self::UnknownType { table, column, raw } => f.write_str(&crate::t!( "persistence.yaml.unknown_type", table = table, column = column, raw = raw, )), Self::UnknownAction(raw) => { f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,)) } } } } impl std::error::Error for YamlError {} fn parse_action(s: &str) -> Option { match s { "no_action" => Some(ReferentialAction::NoAction), "restrict" => Some(ReferentialAction::Restrict), "set_null" => Some(ReferentialAction::SetNull), "cascade" => Some(ReferentialAction::Cascade), _ => None, } } #[derive(Deserialize)] struct RawProject { version: u32, project: RawProjectMeta, #[serde(default)] tables: Vec, #[serde(default)] relationships: Vec, /// Optional: project files written before ADR-0025 carry no /// `indexes:` field and default to an empty list. #[serde(default)] indexes: Vec, } #[derive(Deserialize)] struct RawProjectMeta { created_at: String, /// Optional: pre-#14 project files carry no `mode:` field and /// default to the app's startup mode. Stored as a raw string /// so an unrecognised value degrades to the default rather /// than failing the parse (ADR-0015 mode-restore amendment). #[serde(default)] mode: Option, } #[derive(Deserialize)] struct RawTable { name: String, primary_key: Vec, columns: Vec, /// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2). /// Optional on read — older project files omit it. #[serde(default)] unique_constraints: Vec>, /// Table-level CHECK constraints (ADR-0035 §4a.3, named in §4g). /// Optional on read — older project files omit it. Each entry is a /// bare string (unnamed) or an `{expr, name}` mapping (named). #[serde(default)] check_constraints: Vec, } /// A table-CHECK as read from `project.yaml`: a bare string (unnamed — /// the pre-4g form, back-compatible) or an `{expr, name}` mapping (a /// named CHECK, ADR-0035 §4g). `#[serde(untagged)]` tries the string /// form first, then the mapping. #[derive(Deserialize)] #[serde(untagged)] enum RawTableCheck { Bare(String), Named { expr: String, #[serde(default)] name: Option, }, } impl From for TableCheck { fn from(raw: RawTableCheck) -> Self { match raw { RawTableCheck::Bare(expr) => Self { name: None, expr }, RawTableCheck::Named { expr, name } => Self { name, expr }, } } } #[derive(Deserialize)] struct RawColumn { name: String, #[serde(rename = "type")] user_type: String, /// Optional flag introduced in ADR-0018 for single-column /// UNIQUE constraints. Older project files without this /// field default to `false`. #[serde(default)] unique: bool, /// `NOT NULL` flag (ADR-0029); absent in older files. #[serde(default)] not_null: bool, /// `DEFAULT` SQL literal (ADR-0029); absent in older files. #[serde(default)] default: Option, /// `CHECK` SQL (ADR-0029); absent in older files. #[serde(default)] check: Option, } #[derive(Deserialize)] struct RawRelationship { name: String, parent: RawEndpoint, child: RawEndpoint, on_delete: String, on_update: String, } #[derive(Deserialize)] struct RawEndpoint { table: String, /// FK endpoint column list (ADR-0043): `columns: [a, b]`, one /// element for a single-column endpoint — matching the /// `primary_key` / index `columns` house style. columns: Vec, } #[derive(Deserialize)] struct RawIndex { name: String, table: String, columns: Vec, /// `UNIQUE` index flag (ADR-0035 §4d). Optional on read — project /// files written before unique indexes existed omit it and default /// to `false`. #[serde(default)] unique: bool, } #[cfg(test)] mod tests { use super::*; use crate::dsl::types::Type; fn snapshot() -> SchemaSnapshot { SchemaSnapshot { created_at: "2026-05-07T14:30:12Z".to_string(), mode: Mode::Simple, tables: vec![ TableSchema { name: "Customers".to_string(), primary_key: vec!["id".to_string()], columns: vec![ ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "Name".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None, }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), }, TableSchema { name: "Orders".to_string(), primary_key: vec!["id".to_string()], columns: vec![ ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "CustId".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), }, ], relationships: vec![RelationshipSchema { name: "Customers_id_to_Orders_CustId".to_string(), parent_table: "Customers".to_string(), parent_columns: vec!["id".to_string()], child_table: "Orders".to_string(), child_columns: vec!["CustId".to_string()], on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], indexes: vec![IndexSchema { name: "Orders_CustId_idx".to_string(), table: "Orders".to_string(), columns: vec!["CustId".to_string()], unique: false, }], } } #[test] fn writes_expected_yaml_for_full_schema() { let body = serialize_schema(&snapshot()); // Spot-check structural lines rather than asserting on // the whole blob — easier to read in failure output. assert!(body.contains("version: 1")); assert!(body.contains("created_at: 2026-05-07T14:30:12Z")); assert!(body.contains("- name: Customers")); assert!(body.contains("primary_key: [id]")); assert!(body.contains("{ name: id, type: serial }")); assert!(body.contains("{ name: Name, type: text }")); assert!(body.contains("- name: Customers_id_to_Orders_CustId")); assert!(body.contains("parent: { table: Customers, columns: [id] }")); assert!(body.contains("child: { table: Orders, columns: [CustId] }")); assert!(body.contains("on_delete: cascade")); assert!(body.contains("on_update: no_action")); assert!(body.contains("- name: Orders_CustId_idx")); assert!(body.contains("table: Orders")); assert!(body.contains("columns: [CustId]")); } #[test] fn empty_lists_use_inline_brackets() { let body = serialize_schema(&SchemaSnapshot { created_at: "2026-05-07T14:30:12Z".to_string(), mode: Mode::Simple, tables: vec![], relationships: vec![], indexes: vec![], }); assert!(body.contains("tables: []")); assert!(body.contains("relationships: []")); assert!(body.contains("indexes: []")); } #[test] fn quotes_yaml_keywords_used_as_identifiers() { let body = serialize_schema(&SchemaSnapshot { created_at: "2026-05-07T14:30:12Z".to_string(), mode: Mode::Simple, tables: vec![TableSchema { name: "true".to_string(), // reserved keyword primary_key: vec!["id".to_string()], columns: vec![ColumnSchema { name: "yes".to_string(), user_type: Type::Bool, unique: false, not_null: false, default: None, check: None, }], unique_constraints: Vec::new(), check_constraints: Vec::new(), }], relationships: vec![], indexes: vec![], }); assert!(body.contains("- name: \"true\"")); assert!(body.contains("{ name: \"yes\", type: bool }")); } #[test] fn quotes_strings_with_unsafe_characters() { assert_eq!(quote_if_needed("My Project"), "\"My Project\""); assert_eq!(quote_if_needed("with\"quote"), "\"with\\\"quote\""); } #[test] fn write_then_read_round_trips() { let original = snapshot(); let body = serialize_schema(&original); let parsed = parse_schema(&body).expect("parse schema"); assert_eq!(parsed, original); } #[test] fn unique_index_round_trips_through_yaml() { // ADR-0035 §4d: a UNIQUE index's uniqueness survives a serialize // → parse cycle. A plain index emits no `unique` line; a unique // index emits `unique: true`. let snap = SchemaSnapshot { created_at: "2026-05-25T00:00:00Z".to_string(), mode: Mode::Simple, tables: vec![TableSchema { name: "Customers".to_string(), primary_key: vec!["id".to_string()], columns: vec![ ColumnSchema { name: "id".to_string(), user_type: Type::Serial, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "Email".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None, }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), }], relationships: Vec::new(), indexes: vec![ IndexSchema { name: "Customers_Email_uidx".to_string(), table: "Customers".to_string(), columns: vec!["Email".to_string()], unique: true, }, IndexSchema { name: "Customers_id_idx".to_string(), table: "Customers".to_string(), columns: vec!["id".to_string()], unique: false, }, ], }; let body = serialize_schema(&snap); // The unique index emits the flag; the plain one does not. assert!(body.contains("unique: true"), "yaml:\n{body}"); assert_eq!( body.matches("unique: true").count(), 1, "only the unique index carries the flag:\n{body}" ); let parsed = parse_schema(&body).expect("parse schema"); assert_eq!(parsed, snap); } #[test] fn index_without_unique_field_defaults_to_false() { // Older project files (written before unique indexes) omit the // `unique` field; the `#[serde(default)]` makes it `false`. let body = "\ version: 1 project: created_at: 2026-05-25T00:00:00Z tables: - name: Customers primary_key: [id] columns: - { name: id, type: serial } relationships: [] indexes: - name: Customers_id_idx table: Customers columns: [id] "; let parsed = parse_schema(body).expect("parse schema"); assert_eq!(parsed.indexes.len(), 1); assert!(!parsed.indexes[0].unique); } #[test] fn column_constraints_round_trip_through_yaml() { // NOT NULL / UNIQUE / DEFAULT survive a serialize → // parse cycle (ADR-0029 §7). let snap = SchemaSnapshot { created_at: "2026-05-19T00:00:00Z".to_string(), mode: Mode::Simple, tables: vec![TableSchema { name: "Books".to_string(), primary_key: vec!["isbn".to_string()], columns: vec![ ColumnSchema { name: "isbn".to_string(), user_type: Type::Text, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "title".to_string(), user_type: Type::Text, unique: true, not_null: true, default: Some("'untitled'".to_string()), check: None, }, ColumnSchema { name: "stock".to_string(), user_type: Type::Int, unique: false, not_null: false, default: Some("0".to_string()), check: Some("\"stock\" >= 0".to_string()), }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), }], relationships: vec![], indexes: vec![], }; let body = serialize_schema(&snap); let parsed = parse_schema(&body).expect("parse schema"); assert_eq!(parsed, snap, "constraints survive the yaml round-trip"); } #[test] fn table_level_constraints_round_trip_through_yaml() { // Composite UNIQUE and table-level CHECK (raw SQL text) survive // a serialize → parse cycle in declaration order (ADR-0035 // §4a.2 / §4a.3). let snap = SchemaSnapshot { created_at: "2026-05-25T00:00:00Z".to_string(), mode: Mode::Simple, tables: vec![TableSchema { name: "T".to_string(), primary_key: vec![], columns: vec![ ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "c".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ], unique_constraints: vec![vec!["a".to_string(), "b".to_string()]], check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")], }], relationships: vec![], indexes: vec![], }; let body = serialize_schema(&snap); let parsed = parse_schema(&body).expect("parse schema"); assert_eq!( parsed, snap, "table-level UNIQUE + CHECK survive the yaml round-trip in order" ); } #[test] fn named_check_constraints_round_trip_through_yaml() { // ADR-0035 §4g: a *named* table-CHECK serializes to the `{expr, // name}` mapping form and round-trips, mixed with an unnamed one. let snap = SchemaSnapshot { created_at: "2026-05-25T00:00:00Z".to_string(), mode: Mode::Simple, tables: vec![TableSchema { name: "T".to_string(), primary_key: vec!["id".to_string()], columns: vec![ ColumnSchema { name: "id".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "qty".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ], unique_constraints: vec![], check_constraints: vec![ TableCheck { name: Some("qty_positive".to_string()), expr: "qty >= 0".to_string(), }, TableCheck::unnamed("qty < 1000"), ], }], relationships: vec![], indexes: vec![], }; let body = serialize_schema(&snap); let parsed = parse_schema(&body).expect("parse schema"); assert_eq!( parsed, snap, "named + unnamed table-CHECKs survive the yaml round-trip" ); } #[test] fn old_format_bare_string_check_constraints_still_parse() { // Back-compat: a project file written before §4g (bare-string // check_constraints) parses with name = None. let body = "\ version: 1 project: created_at: \"2026-05-25T00:00:00Z\" tables: - name: T primary_key: [id] columns: - { name: id, type: int } - { name: qty, type: int } check_constraints: - \"qty >= 0\" relationships: [] indexes: [] "; let parsed = parse_schema(body).expect("parse old-format schema"); assert_eq!( parsed.tables[0].check_constraints, vec![TableCheck::unnamed("qty >= 0")], "a bare-string CHECK parses as an unnamed TableCheck" ); } #[test] fn check_constraints_optional_on_read() { // A project file written before table-level CHECK existed (no // `check_constraints:` key) parses with an empty list. let body = "\ version: 1 project: created_at: 2026-05-25T00:00:00Z tables: - name: T primary_key: [id] columns: - { name: id, type: int } relationships: [] "; let parsed = parse_schema(body).expect("parse"); assert!(parsed.tables[0].check_constraints.is_empty()); } #[test] fn parses_minimal_yaml_with_no_tables() { let body = "\ version: 1 project: created_at: 2026-05-07T14:30:12Z tables: [] relationships: [] "; let parsed = parse_schema(body).expect("parse minimal"); assert_eq!(parsed.tables.len(), 0); assert_eq!(parsed.relationships.len(), 0); // A project file with no `indexes:` field (written // before ADR-0025) parses with an empty index list. assert_eq!(parsed.indexes.len(), 0); assert_eq!(parsed.created_at, "2026-05-07T14:30:12Z"); } #[test] fn rejects_unknown_version() { let body = "version: 9\nproject:\n created_at: x\ntables: []\nrelationships: []\n"; match parse_schema(body) { Err(YamlError::UnsupportedVersion(9)) => {} other => panic!("expected UnsupportedVersion(9), got {other:?}"), } } #[test] fn rejects_unknown_column_type() { let body = "\ version: 1 project: created_at: x tables: - name: T primary_key: [id] columns: - { name: id, type: bogus } relationships: [] "; match parse_schema(body) { Err(YamlError::UnknownType { raw, .. }) => assert_eq!(raw, "bogus"), other => panic!("expected UnknownType, got {other:?}"), } } #[test] fn rejects_unknown_action() { let body = "\ version: 1 project: created_at: x tables: [] relationships: - name: R parent: { table: A, columns: [id] } child: { table: B, columns: [aid] } on_delete: blow_up on_update: no_action "; match parse_schema(body) { Err(YamlError::UnknownAction(s)) => assert_eq!(s, "blow_up"), other => panic!("expected UnknownAction, got {other:?}"), } } #[test] fn preserves_compound_primary_key_order() { let body = serialize_schema(&SchemaSnapshot { created_at: "2026-05-07T14:30:12Z".to_string(), mode: Mode::Simple, tables: vec![TableSchema { name: "Items".to_string(), primary_key: vec!["a".to_string(), "b".to_string()], columns: vec![ ColumnSchema { name: "a".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ColumnSchema { name: "b".to_string(), user_type: Type::Int, unique: false, not_null: false, default: None, check: None, }, ], unique_constraints: Vec::new(), check_constraints: Vec::new(), }], relationships: vec![], indexes: vec![], }); assert!(body.contains("primary_key: [a, b]")); } // ---- ADR-0015 mode-restore amendment (issue #14) ---- #[test] fn mode_round_trips_through_serialize_and_parse() { for mode in [Mode::Simple, Mode::Advanced] { let snap = SchemaSnapshot { created_at: "2026-05-07T14:30:12Z".to_string(), mode, tables: vec![], relationships: vec![], indexes: vec![], }; let body = serialize_schema(&snap); assert!( body.contains(&format!("mode: {}", mode.keyword())), "serialized body carries the mode keyword: {body}" ); let parsed = parse_schema(&body).expect("round-trips"); assert_eq!(parsed.mode, mode); } } #[test] fn parse_schema_defaults_mode_to_simple_when_field_absent() { // A pre-#14 project file carries no `mode:` field; it must // parse with the default mode, not fail. let body = "version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n"; let parsed = parse_schema(body).expect("legacy file parses"); assert_eq!(parsed.mode, Mode::Simple); } #[test] fn parse_stored_mode_distinguishes_absent_from_explicit() { // `None` (no stored preference) must be distinct from an // explicit `simple`, so restore-on-open precedence can tell // "fall back to default" from "the user chose simple". let absent = "version: 1\nproject:\n created_at: x\ntables: []\n"; assert_eq!(parse_stored_mode(absent), None); let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n"; assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple)); let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n"; assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced)); } #[test] fn parse_stored_mode_falls_back_to_none_on_unknown_value() { // An unrecognised mode keyword degrades to "no preference" // rather than rejecting the whole file over a UI hint. let body = "version: 1\nproject:\n created_at: x\n mode: expert\ntables: []\n"; assert_eq!(parse_stored_mode(body), None); } }