DazedTL/util/wolfdawn-master/crates/wolf-gui/src/database.rs
2026-07-03 13:54:07 -05:00

678 lines
29 KiB
Rust

//! The Database section's data model and the headless logic behind it.
//!
//! A spreadsheet-like editor over a Wolf database (`*.project` + sibling `*.dat`) so a non-coder can
//! change values like item prices, skill power, or enemy HP without touching JSON. This module owns
//! everything *except* the egui rendering (which lives in `app.rs`), so the whole load/edit/save
//! flow can be exercised headlessly in tests.
//!
//! It mirrors the CLI's `db-json` (`database_to_json`) + `db-apply` (`apply_database_edit`) pair,
//! calling the **same** `wolf_decompiler` library functions directly (no shelling out), so the GUI
//! and CLI agree on the JSON shape and the byte-exact apply:
//!
//! * [`load`] reads the `.project`+`.dat` pair with [`Database::read`], renders it to JSON with
//! [`database_to_json`] (exactly what `cmd_db_json` does, via the same per-stem `kind` label),
//! parses it into a [`DbModel`] of [`DbType`]s -> [`DbRow`]s -> [`Cell`]s, and **keeps the parsed
//! `doc`** so a save can rewrite just the edited leaves. Each editable cell carries a JSON
//! Pointer (RFC 6901) to its leaf in `doc`, exactly like `translation.rs`, so an edit touches
//! one leaf and every other byte stays identical.
//! * [`save`] writes each row's edited value into the leaf its pointer addresses, serializes the
//! patched `doc`, and applies it with [`apply_database_edit`] onto a freshly-read base (the
//! `db-apply` path), re-parses the [`Database::write`] output to verify it round-trips, and
//! writes the `.project`+`.dat` pair together byte-exact, optionally backing up the originals
//! first. Untouched data re-serializes verbatim, so a no-change save reproduces both halves
//! byte-for-byte.
//!
//! The DB `.project` is the canonical handle. The sibling `.dat` is paired by extension, mirroring
//! the CLI's `cmd_db_apply` (`base.with_extension("dat")`).
use std::path::{Path, PathBuf};
use serde_json::Value;
use wolf_decompiler::{apply_database_edit, database_to_json};
use wolf_formats::database::Database;
/// One editable cell of a data row: which field column it belongs to, the editable value (as text -
/// ints are edited as their decimal string and validated on apply), whether it is a string or int
/// field (so the grid can hint and the apply path can re-type it), and the JSON Pointer to this
/// cell's leaf inside the owning [`DbModel::doc`]. The pointer is what makes a save lossless: an edit
/// rewrites exactly that one leaf, leaving every other byte of the document untouched.
#[derive(Clone)]
pub struct Cell {
/// The owning field's display key (the same key the JSON `values` object uses).
pub field: String,
/// The editable value, as text (string cells verbatim, int cells as their decimal form).
pub value: String,
/// The value as loaded (read-only, for change detection).
pub original: String,
/// `true` for a string field, `false` for an int field.
pub is_string: bool,
/// RFC 6901 JSON Pointer to this cell's value leaf inside [`DbModel::doc`].
ptr: String,
}
impl Cell {
/// True when the user changed this cell from its loaded value.
pub fn is_changed(&self) -> bool {
self.value != self.original
}
}
/// One data row of a type: its editable display name (+ pointer) plus one [`Cell`] per data field.
#[derive(Clone)]
pub struct DbRow {
/// The row's stable id (its index in the type's data), shown for orientation.
pub id: usize,
/// The editable row name (the first grid column).
pub name: String,
/// The name as loaded (read-only, for change detection).
pub name_original: String,
/// JSON Pointer to this row's `name` leaf inside [`DbModel::doc`].
name_ptr: String,
/// One cell per data field, in column order.
pub cells: Vec<Cell>,
}
impl DbRow {
/// How many of this row's editable leaves changed (name counts as one).
pub fn changed_count(&self) -> usize {
let n = usize::from(self.name != self.name_original);
n + self.cells.iter().filter(|c| c.is_changed()).count()
}
}
/// One database type (タイプ), a table like "Skill", "Item", "Enemy": its display name, the data
/// field column names (in order), and its data rows.
pub struct DbType {
/// The type's display name (e.g. `技能 / Skill`).
pub name: String,
/// The data field column names, in order (the grid header, after the row-name column).
pub fields: Vec<String>,
/// The data rows.
pub rows: Vec<DbRow>,
}
impl DbType {
/// How many editable leaves the user changed across this type.
pub fn changed_count(&self) -> usize {
self.rows.iter().map(DbRow::changed_count).sum()
}
}
/// The loaded, editable view of a database: where it came from (the `.project`, with the `.dat`
/// paired by extension), the read-only kind/encoding badges, and the types.
///
/// The full parsed JSON document is NOT kept resident. It is the heaviest part of a large database
/// (every value and a repeated key per row, plus map overhead), so save/export rebuild it on demand
/// from the base file and apply the edits then. The editable grid keeps only what a user can change
/// (cell values + row names) plus each leaf's JSON pointer.
pub struct DbModel {
/// The on-disk `.project` this was loaded from (the [`save`] in-place target, `.dat` is sibling).
pub project: PathBuf,
/// The DB kind label (`UDB` / `CDB` / `SDB`), read-only.
pub kind: String,
/// The text encoding badge (`UTF-8` / `Shift-JIS`), read-only.
pub encoding: String,
/// The types (tables), in order.
pub types: Vec<DbType>,
}
impl DbModel {
/// Total data rows across every type.
pub fn total_rows(&self) -> usize {
self.types.iter().map(|t| t.rows.len()).sum()
}
/// How many editable leaves the user changed across the whole model.
pub fn changed_count(&self) -> usize {
self.types.iter().map(DbType::changed_count).sum()
}
/// True when anything was edited (so the Save button can gate on it).
pub fn dirty(&self) -> bool {
self.changed_count() > 0
}
}
/// The per-stem `kind` label, matching the CLI's `db_json_one` exactly.
fn kind_for(proj: &Path) -> &'static str {
match proj.file_stem().and_then(|s| s.to_str()) {
Some("DataBase") => "UDB",
Some("CDataBase") => "CDB",
Some("SysDatabase") => "SDB",
_ => "DB",
}
}
/// Escape one path token for an RFC 6901 JSON Pointer (`~` -> `~0`, `/` -> `~1`). Field keys can
/// contain `/` (e.g. `発動回数/武器の影響`) so this must run on every dynamic segment.
fn esc(token: &str) -> String {
token.replace('~', "~0").replace('/', "~1")
}
/// Read and parse the `.project`+`.dat` pair into the editable grid model, reusing [`Database::read`]
/// + [`database_to_json`] (the CLI's `db-json` path) with the same per-stem `kind` label. The
/// returned model keeps the parsed JSON so a later [`save`] rewrites only the edited leaves.
pub fn load(project: &Path) -> Result<DbModel, String> {
let dat = project.with_extension("dat");
let pb = std::fs::read(project)
.map_err(|e| format!("cannot read {}: {e}", project.display()))?;
let db_ = std::fs::read(&dat).map_err(|e| format!("cannot read {}: {e}", dat.display()))?;
let db = Database::read(&pb, &db_).map_err(|e| format!("database parse failed: {e}"))?;
let kind = kind_for(project);
let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary();
let json = database_to_json(&db, kind, &glossary);
let doc: Value = serde_json::from_str(&json).map_err(|e| format!("invalid DB JSON: {e}"))?;
let kind_s = doc
.get("kind")
.and_then(Value::as_str)
.unwrap_or(kind)
.to_string();
let encoding = doc
.get("encoding")
.and_then(Value::as_str)
.unwrap_or("UTF-8")
.to_string();
let types = types_from_doc(&doc);
// `doc` is dropped here: the grid carries everything editable, and save/export rebuild the doc
// from the base file when needed, so it is not held for the session.
Ok(DbModel {
project: project.to_path_buf(),
kind: kind_s,
encoding,
types,
})
}
/// Build the editable types/rows/cells (each carrying its JSON pointer) from the parsed document.
fn types_from_doc(doc: &Value) -> Vec<DbType> {
let mut out = Vec::new();
let Some(types) = doc.get("types").and_then(Value::as_array) else {
return out;
};
for (idx, t) in types.iter().enumerate() {
let ti = t.get("id").and_then(Value::as_u64).map(|n| n as usize).unwrap_or(idx);
let type_name = display_name(t);
// The data field columns are the keys of the FIRST row's `values` object, in order (only the
// `fields_size` data fields appear there, schema-only fields are absent, matching the export).
// We derive the column order + each cell's string/int kind from the `fields` schema, keyed by
// the same `values` key (unique field name, else `#<id>`), so a missing first row still works.
let columns = value_columns(t);
let field_labels: Vec<String> = columns.iter().map(|c| c.label.clone()).collect();
let mut rows = Vec::new();
if let Some(rs) = t.get("rows").and_then(Value::as_array) {
for r in rs {
let ri = r.get("id").and_then(Value::as_u64).unwrap_or(0) as usize;
let name = r.get("name").and_then(Value::as_str).unwrap_or("").to_string();
let mut cells = Vec::with_capacity(columns.len());
for col in &columns {
let v = r.get("values").and_then(|vv| vv.get(&col.key));
let value = match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(other) => other.to_string(),
None => String::new(),
};
cells.push(Cell {
field: col.label.clone(),
original: value.clone(),
value,
is_string: col.is_string,
ptr: format!("/types/{ti}/rows/{ri}/values/{}", esc(&col.key)),
});
}
rows.push(DbRow {
id: ri,
name_original: name.clone(),
name,
name_ptr: format!("/types/{ti}/rows/{ri}/name"),
cells,
});
}
}
out.push(DbType {
name: type_name,
fields: field_labels,
rows,
});
}
out
}
/// A data column derived from a type's schema: the `values` key, a friendly label, and the kind.
struct Column {
/// The key as it appears in each row's `values` object.
key: String,
/// A friendly column label (`name_en` when present, else the JP `name`).
label: String,
/// `true` for a string field.
is_string: bool,
}
/// Derive the ordered data columns from a type's `fields` schema. A field carries row data unless it
/// is tagged `schemaOnly: true`. Its `values` key is its unique field name, else `#<id>`, the same
/// rule `value_slots`/`database_to_json` use, so the keys always match the row objects.
fn value_columns(t: &Value) -> Vec<Column> {
let Some(fields) = t.get("fields").and_then(Value::as_array) else {
return Vec::new();
};
// Count name occurrences among data-bearing fields so duplicates fall back to `#<id>`.
let data_fields: Vec<&Value> = fields
.iter()
.filter(|f| !f.get("schemaOnly").and_then(Value::as_bool).unwrap_or(false))
.collect();
let mut name_counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
for f in &data_fields {
let n = f.get("name").and_then(Value::as_str).unwrap_or("");
if !n.is_empty() {
*name_counts.entry(n).or_default() += 1;
}
}
let mut out = Vec::with_capacity(data_fields.len());
for f in &data_fields {
let fi = f.get("id").and_then(Value::as_u64).unwrap_or(0) as usize;
let jp = f.get("name").and_then(Value::as_str).unwrap_or("");
let en = f.get("name_en").and_then(Value::as_str);
let key = if jp.is_empty() || name_counts.get(jp).copied().unwrap_or(0) > 1 {
format!("#{fi}")
} else {
jp.to_string()
};
let label = match en {
Some(e) if !e.is_empty() && e != jp => format!("{jp} / {e}"),
_ if jp.is_empty() => format!("#{fi}"),
_ => jp.to_string(),
};
let is_string = f.get("kind").and_then(Value::as_str) == Some("string");
out.push(Column { key, label, is_string });
}
out
}
/// A friendly display name for a type/row: `name_en` appended when present (`JP / EN`), else JP.
fn display_name(v: &Value) -> String {
let jp = v.get("name").and_then(Value::as_str).unwrap_or("");
match v.get("name_en").and_then(Value::as_str) {
Some(en) if !en.is_empty() && en != jp => format!("{jp} / {en}"),
_ => jp.to_string(),
}
}
// ----------------------------------------------------------------------------
// Save / export: rebuild the doc from the base, patch in the edits, apply + write
// ----------------------------------------------------------------------------
/// Push each row's edited name + cell values into `doc` (a freshly rebuilt `database_to_json`
/// document), via each leaf's JSON pointer, so the document is current before it is fed to
/// `apply_database_edit`. Int cells write a JSON number (so the apply path's `as_i64` accepts them).
/// String cells write a JSON string. An int cell that does not parse is a hard error here with a
/// clear field reference.
fn apply_edits_to_doc(model: &DbModel, doc: &mut Value) -> Result<(), String> {
for t in &model.types {
for r in &t.rows {
if r.name != r.name_original {
if let Some(slot) = doc.pointer_mut(&r.name_ptr) {
*slot = Value::String(r.name.clone());
}
}
for c in &r.cells {
if !c.is_changed() {
continue;
}
let Some(slot) = doc.pointer_mut(&c.ptr) else {
continue;
};
if c.is_string {
*slot = Value::String(c.value.clone());
} else {
let n: i64 = c.value.trim().parse().map_err(|_| {
format!(
"field {:?} in type {:?} row {} ({:?}): {:?} is not a whole number",
c.field, t.name, r.id, r.name, c.value
)
})?;
*slot = Value::Number(n.into());
}
}
}
}
Ok(())
}
/// Rebuild the full `database_to_json` document from the base `.project`+`.dat` and patch in the
/// grid's edits. The doc is not kept resident, so this re-reads the base (deterministic, same
/// structure as load) and applies the edits via their pointers. Returns the edited doc plus the base
/// bytes, so [`save`] can apply onto the same bytes without a second read.
fn edited_doc(model: &DbModel) -> Result<(Value, Vec<u8>, Vec<u8>), String> {
let base_proj = &model.project;
let base_dat = base_proj.with_extension("dat");
let pb = std::fs::read(base_proj)
.map_err(|e| format!("cannot read base {}: {e}", base_proj.display()))?;
let db_ = std::fs::read(&base_dat)
.map_err(|e| format!("cannot read base {}: {e}", base_dat.display()))?;
let db = Database::read(&pb, &db_).map_err(|e| format!("base parse failed: {e}"))?;
let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary();
let json = database_to_json(&db, kind_for(base_proj), &glossary);
let mut doc: Value = serde_json::from_str(&json).map_err(|e| format!("invalid DB JSON: {e}"))?;
apply_edits_to_doc(model, &mut doc)?;
Ok((doc, pb, db_))
}
/// Apply an edits JSON onto a fresh base, verify the result re-parses, then write the `.project`+
/// `.dat` pair (backing up existing halves first when asked). The shared tail of [`save`] and
/// [`import_json`]. `base_pb`/`base_dat_bytes`, when supplied, are the already-read base bytes (save
/// reuses them); pass `None` to read the base here (import).
fn apply_and_write(
base_project: &Path,
edited_json: &str,
out_project: &Path,
backup: bool,
base_bytes: Option<(Vec<u8>, Vec<u8>)>,
) -> Result<SaveOutcome, String> {
let base_dat = base_project.with_extension("dat");
let out_dat = out_project.with_extension("dat");
let (pb, db_) = match base_bytes {
Some(b) => b,
None => (
std::fs::read(base_project)
.map_err(|e| format!("cannot read base {}: {e}", base_project.display()))?,
std::fs::read(&base_dat)
.map_err(|e| format!("cannot read base {}: {e}", base_dat.display()))?,
),
};
let mut db = Database::read(&pb, &db_).map_err(|e| format!("base parse failed: {e}"))?;
let changed =
apply_database_edit(edited_json, &mut db).map_err(|e| format!("apply failed: {e}"))?;
let (out_pb, out_db) = db.write();
// Re-parse the output to verify it still round-trips before committing it. Never ship a
// structurally-corrupt pair.
Database::read(&out_pb, &out_db)
.map_err(|e| format!("internal error: edited database no longer parses ({e})"))?;
let byte_identical = out_pb == pb && out_db == db_;
let mut backups = Vec::new();
if backup {
for half in [out_project, out_dat.as_path()] {
if half.exists() {
let bak = backup_path(half);
std::fs::copy(half, &bak)
.map_err(|e| format!("backup failed ({}): {e}", bak.display()))?;
backups.push(bak);
}
}
}
if let Some(parent) = out_project.parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
if let (Err(e), _) | (_, Err(e)) = (
std::fs::write(out_project, &out_pb),
std::fs::write(&out_dat, &out_db),
) {
return Err(format!("write failed: {e}"));
}
Ok(SaveOutcome {
changed,
byte_identical,
out_project: out_project.to_path_buf(),
out_dat,
backups,
})
}
/// Export the on-disk database as a pretty-printed `database_to_json` document for editing outside
/// the GUI (re-import with [`import_json`]). Reads only the base path, so it runs on a worker without
/// borrowing the live grid. Grid edits are NOT included here, so Save first to bake them in.
pub fn export_json(base_project: &Path, out: &Path) -> Result<(), String> {
let base_dat = base_project.with_extension("dat");
let pb = std::fs::read(base_project)
.map_err(|e| format!("cannot read {}: {e}", base_project.display()))?;
let db_ = std::fs::read(&base_dat)
.map_err(|e| format!("cannot read {}: {e}", base_dat.display()))?;
let db = Database::read(&pb, &db_).map_err(|e| format!("database parse failed: {e}"))?;
let glossary = wolf_decompiler::symbols::load_embedded_engine_glossary();
let json = database_to_json(&db, kind_for(base_project), &glossary);
let doc: Value = serde_json::from_str(&json).map_err(|e| format!("invalid DB JSON: {e}"))?;
let pretty = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
std::fs::write(out, pretty.as_bytes())
.map_err(|e| format!("cannot write {}: {e}", out.display()))?;
Ok(())
}
/// Import an edited `database_to_json` document (full or patch-style), apply it onto `base_project`'s
/// pair, and write to `out_project`. The same verify-then-write guard as [`save`].
pub fn import_json(
base_project: &Path,
json_path: &Path,
out_project: &Path,
backup: bool,
) -> Result<SaveOutcome, String> {
let json = std::fs::read_to_string(json_path)
.map_err(|e| format!("cannot read {}: {e}", json_path.display()))?;
apply_and_write(base_project, &json, out_project, backup, None)
}
/// Where a backup goes for an in-place save of one half.
fn backup_path(file: &Path) -> PathBuf {
let mut name = file.file_name().map(|s| s.to_os_string()).unwrap_or_default();
name.push(".bak");
file.with_file_name(name)
}
/// The outcome of a [`save`] run, for the log.
pub struct SaveOutcome {
/// How many cells/names `apply_database_edit` actually changed.
pub changed: usize,
/// True when both output halves are byte-identical to the base (a no-op save, or edits that net
/// to the original values).
pub byte_identical: bool,
/// Where the `.project` was written.
pub out_project: PathBuf,
/// Where the `.dat` was written.
pub out_dat: PathBuf,
/// The backup paths made (`.project.bak`, `.dat.bak`), if any.
pub backups: Vec<PathBuf>,
}
/// Apply the grid's edits onto the base database and write the `.project`+`.dat` pair to
/// `out_project` (+ its sibling `.dat`), mirroring the CLI's `db-apply`: patch the parsed doc with
/// the edited leaves, [`apply_database_edit`] it onto a freshly-read base, **re-parse the
/// [`Database::write`] output to verify it round-trips** before writing, and report the change count
/// + byte-identity. When `backup` is set and an output half already exists, the original is copied to
/// `<half>.bak` first.
///
/// Byte-exactness: `apply_database_edit` only rewrites cells whose value changed (it skips unchanged
/// strings to dodge the empty-string / trailing-NUL re-encode ambiguity), so untouched data
/// re-serializes verbatim and a no-change save reproduces both halves byte-for-byte.
pub fn save(model: &DbModel, out_project: &Path, backup: bool) -> Result<SaveOutcome, String> {
let (doc, pb, db_) = edited_doc(model)?;
let edited_json = serde_json::to_string(&doc).map_err(|e| e.to_string())?;
apply_and_write(&model.project, &edited_json, out_project, backup, Some((pb, db_)))
}
#[cfg(test)]
mod tests {
use super::*;
/// A unique temp dir for scratch output (never a game folder or the fixtures root).
fn tmp_dir(tag: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"wolfdawn_db_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::create_dir_all(&p);
p
}
/// Resolve a fixture by its clean relative path under `WOLFDAWN_TEST_DATA`. Returns `None` when
/// the var is unset or the file is missing, so the data-dependent tests skip gracefully.
fn test_data(rel: &str) -> Option<PathBuf> {
let base = std::env::var_os("WOLFDAWN_TEST_DATA")?;
let p = std::path::Path::new(&base).join(rel);
p.exists().then_some(p)
}
/// The shared DB fixture (`.project` plus its `.dat` sibling); tests skip when it is absent.
fn fixture() -> Option<PathBuf> {
test_data("chamber/Data/BasicData/DataBase.project")
.filter(|p| p.with_extension("dat").exists())
}
/// Copy the `.project`+`.dat` pair into `dir`, returning the copied `.project` path.
fn copy_pair(src_proj: &Path, dir: &Path) -> PathBuf {
let stem = src_proj.file_name().unwrap();
let dst_proj = dir.join(stem);
let dst_dat = dst_proj.with_extension("dat");
std::fs::copy(src_proj, &dst_proj).expect("copy .project");
std::fs::copy(src_proj.with_extension("dat"), &dst_dat).expect("copy .dat");
dst_proj
}
/// Load a real DB, edit one cell's value, save to a temp output, reload from the output: the
/// edited cell shows the new value and an UNEDITED type/row is unchanged. Never touches the
/// fixtures root (reads the fixture, copies + writes only into a temp dir).
#[test]
fn load_edit_cell_save_reload_round_trip() {
let Some(src) = fixture() else {
eprintln!("skip load_edit_cell_save_reload_round_trip: no DB fixture present");
return;
};
let work = tmp_dir("rt");
let base = copy_pair(&src, &work);
let mut m = load(&base).expect("load");
assert!(!m.types.is_empty(), "DB should have at least one type");
assert!(m.total_rows() >= 1, "DB should have at least one data row");
// Find a type with at least one row that has at least one cell, and edit the first cell.
let (ti, ri, ci, new_value, is_string) = {
let mut found = None;
'outer: for (ti, t) in m.types.iter().enumerate() {
for (ri, r) in t.rows.iter().enumerate() {
if !r.cells.is_empty() {
found = Some((ti, ri));
break 'outer;
}
}
}
let (ti, ri) = found.expect("a type/row with at least one cell");
let cell = &m.types[ti].rows[ri].cells[0];
// For a string cell, append a marker. For an int cell, bump it by 1 (stays a valid int).
let new = if cell.is_string {
format!("{}_EDIT", cell.value)
} else {
let cur: i64 = cell.value.trim().parse().unwrap_or(0);
(cur + 1).to_string()
};
(ti, ri, 0usize, new, cell.is_string)
};
// Record an UNEDITED reference cell elsewhere (a different row, or a different type).
let reference = {
let mut chosen = None;
'r: for (oti, t) in m.types.iter().enumerate() {
for (ori, r) in t.rows.iter().enumerate() {
if (oti, ori) != (ti, ri) && !r.cells.is_empty() {
chosen = Some((oti, ori, r.cells[0].value.clone()));
break 'r;
}
}
}
chosen
};
m.types[ti].rows[ri].cells[ci].value = new_value.clone();
assert_eq!(m.changed_count(), 1, "exactly one cell changed");
let out = work.join("edited.project");
let outcome = save(&mut m, &out, false).expect("save");
assert_eq!(outcome.changed, 1, "apply should report one cell changed");
assert!(!outcome.byte_identical, "an edited save differs from base");
assert!(out.with_extension("dat").exists(), "the .dat half was written");
// Reload from the OUTPUT pair: the edited cell shows the new value.
let re = load(&out).expect("reload");
let got = &re.types[ti].rows[ri].cells[ci];
assert_eq!(got.value, new_value, "edited cell shows the new value");
assert_eq!(got.is_string, is_string, "cell kind preserved");
// The unedited reference cell is unchanged.
if let Some((oti, ori, orig)) = reference {
assert_eq!(
re.types[oti].rows[ori].cells[0].value, orig,
"an unedited cell must be unchanged after the round-trip"
);
}
let _ = std::fs::remove_dir_all(&work);
}
/// A no-change save is byte-exact: load -> save with no edits -> both output halves are
/// byte-identical to the inputs (lossless-pointer + db-apply's byte-exact pairing).
#[test]
fn no_change_save_is_byte_exact() {
let Some(src) = fixture() else {
eprintln!("skip no_change_save_is_byte_exact: no DB fixture present");
return;
};
let work = tmp_dir("noop");
let base = copy_pair(&src, &work);
let in_proj = std::fs::read(&base).unwrap();
let in_dat = std::fs::read(base.with_extension("dat")).unwrap();
let mut m = load(&base).expect("load");
assert_eq!(m.changed_count(), 0, "nothing edited yet");
let out = work.join("out.project");
let outcome = save(&mut m, &out, false).expect("save");
assert_eq!(outcome.changed, 0, "no cells changed");
assert!(outcome.byte_identical, "a no-change save must be byte-identical to base");
assert_eq!(std::fs::read(&out).unwrap(), in_proj, ".project is byte-identical");
assert_eq!(
std::fs::read(out.with_extension("dat")).unwrap(),
in_dat,
".dat is byte-identical"
);
let _ = std::fs::remove_dir_all(&work);
}
/// In-place save with backups: both `.project.bak` and `.dat.bak` hold the original bytes.
#[test]
fn in_place_save_backs_up_both_halves() {
let Some(src) = fixture() else {
eprintln!("skip in_place_save_backs_up_both_halves: no DB fixture present");
return;
};
let work = tmp_dir("bak");
let base = copy_pair(&src, &work);
let orig_proj = std::fs::read(&base).unwrap();
let orig_dat = std::fs::read(base.with_extension("dat")).unwrap();
let mut m = load(&base).expect("load");
// Edit a row name so a save actually rewrites bytes.
let row = m
.types
.iter_mut()
.find(|t| !t.rows.is_empty())
.map(|t| &mut t.rows[0])
.expect("a row");
row.name = format!("{}_X", row.name);
let outcome = save(&mut m, &base, true).expect("save in place");
assert_eq!(outcome.backups.len(), 2, "both halves backed up");
assert_eq!(std::fs::read(&outcome.backups[0]).unwrap(), orig_proj);
assert_eq!(std::fs::read(&outcome.backups[1]).unwrap(), orig_dat);
let _ = std::fs::remove_dir_all(&work);
}
}