Iteration 2: per-command write-through to project.yaml, CSVs, history.log
Every successful user command now persists through to YAML, the
affected CSVs, and history.log inside the same SQLite transaction,
with the commit-db-last ordering from ADR-0015 §6: validate ->
mutate -> stage text + fsync -> atomic rename -> append history ->
commit. A failure in any text-write step rolls back the SQLite tx,
so disk state is unchanged on failure. Persistence failures are
routed through a new AppEvent::PersistenceFatal which sets a
fatal_message on the App, emits Action::Quit, and is printed to
stderr after terminal teardown so the banner remains above the
shell prompt (ADR-0015 §8).
New persistence module owns the file formats: hand-rolled YAML
schema writer, per-type CSV encoder (RFC 4180, NULL distinct from
empty string, base64 blobs), append-only history.log with ISO-8601
timestamps and successful-only entries. Atomic per-file writes via
tmp + fsync + rename.
The db worker holds an Option<Persistence>; tests still use
Database::open(":memory:") with no persistence. Action::ExecuteDsl
gains a source field carrying the user-typed text, threaded
through to history.log.
Tests: 289 passing (256 lib + 7 new integration + 9 lifecycle + 17
walking-skeleton), 0 failing, 0 skipped. Clippy clean with nursery
lints.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
//! Hand-rolled `project.yaml` writer (ADR-0015 §3).
|
||||
//!
|
||||
//! 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 this file. The reader (Iteration 3) will use
|
||||
//! a real YAML parser.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::dsl::action::ReferentialAction;
|
||||
|
||||
use super::{ColumnSchema, RelationshipSchema, SchemaSnapshot, 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));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_column(out: &mut String, col: &ColumnSchema) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" - {{ name: {}, type: {} }}",
|
||||
quote_if_needed(&col.name),
|
||||
col.user_type.keyword(),
|
||||
);
|
||||
}
|
||||
|
||||
fn write_relationship(out: &mut String, rel: &RelationshipSchema) {
|
||||
let _ = writeln!(out, " - name: {}", quote_if_needed(&rel.name));
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" parent: {{ table: {}, column: {} }}",
|
||||
quote_if_needed(&rel.parent_table),
|
||||
quote_if_needed(&rel.parent_column),
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" child: {{ table: {}, column: {} }}",
|
||||
quote_if_needed(&rel.child_table),
|
||||
quote_if_needed(&rel.child_column),
|
||||
);
|
||||
let _ = writeln!(out, " on_delete: {}", action_keyword(rel.on_delete));
|
||||
let _ = writeln!(out, " on_update: {}", action_keyword(rel.on_update));
|
||||
}
|
||||
|
||||
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, '_' | '-' | '.' | ':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
fn snapshot() -> SchemaSnapshot {
|
||||
SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
tables: vec![
|
||||
TableSchema {
|
||||
name: "Customers".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial },
|
||||
ColumnSchema { name: "Name".to_string(), user_type: Type::Text },
|
||||
],
|
||||
},
|
||||
TableSchema {
|
||||
name: "Orders".to_string(),
|
||||
primary_key: vec!["id".to_string()],
|
||||
columns: vec![
|
||||
ColumnSchema { name: "id".to_string(), user_type: Type::Serial },
|
||||
ColumnSchema { name: "CustId".to_string(), user_type: Type::Int },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: vec![RelationshipSchema {
|
||||
name: "Customers_id_to_Orders_CustId".to_string(),
|
||||
parent_table: "Customers".to_string(),
|
||||
parent_column: "id".to_string(),
|
||||
child_table: "Orders".to_string(),
|
||||
child_column: "CustId".to_string(),
|
||||
on_delete: ReferentialAction::Cascade,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[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, column: id }"));
|
||||
assert!(body.contains("child: { table: Orders, column: CustId }"));
|
||||
assert!(body.contains("on_delete: cascade"));
|
||||
assert!(body.contains("on_update: no_action"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_lists_use_inline_brackets() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
tables: vec![],
|
||||
relationships: vec![],
|
||||
});
|
||||
assert!(body.contains("tables: []"));
|
||||
assert!(body.contains("relationships: []"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_yaml_keywords_used_as_identifiers() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
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,
|
||||
}],
|
||||
}],
|
||||
relationships: 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 preserves_compound_primary_key_order() {
|
||||
let body = serialize_schema(&SchemaSnapshot {
|
||||
created_at: "2026-05-07T14:30:12Z".to_string(),
|
||||
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 },
|
||||
ColumnSchema { name: "b".to_string(), user_type: Type::Int },
|
||||
],
|
||||
}],
|
||||
relationships: vec![],
|
||||
});
|
||||
assert!(body.contains("primary_key: [a, b]"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user