diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 112c2f6..b521def 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -239,10 +239,9 @@ impl Prefs { } /// Serialize back to the [`PREFS_PATH`] form, with a header comment pointing at - /// both edit paths. Round-trips with [`parse`](Prefs::parse). Emitted *without* - /// a trailing newline: like the editor buffer, this is content without its - /// terminator — the persistence layer appends the single POSIX newline on write - /// (and strips it back on read), so returning one here would double it. + /// both edit paths. Round-trips with [`parse`](Prefs::parse). Ends in a newline + /// like a normal text file; `save_path`'s guarded final-newline write leaves it + /// as exactly one. pub fn to_toml(&self) -> String { format!( "# Typoena editor preferences — hand-editable, git-tracked.\n\ @@ -250,7 +249,7 @@ impl Prefs { save_on_idle = {}\n\ format_on_save = {}\n\ line_numbers = {}\n\ - auto_sync = \"{}\"", + auto_sync = \"{}\"\n", self.save_on_idle, self.format_on_save, self.line_numbers, self.auto_sync, ) } diff --git a/firmware/src/bin/sd_fat.rs b/firmware/src/bin/sd_fat.rs index 812d00a..ef3e4b7 100644 --- a/firmware/src/bin/sd_fat.rs +++ b/firmware/src/bin/sd_fat.rs @@ -94,11 +94,12 @@ fn write_test(storage: &Storage) -> Result<()> { log::info!("{REPO_DIR} missing — creating it (bench setup) so the write test can run"); fs::create_dir_all(REPO_DIR).with_context(|| format!("create {REPO_DIR}"))?; } - // Newline-free, matching a real editor buffer: `save` appends one POSIX - // terminator and `load` strips one back, so the round-trip is byte-for-byte - // identical for any payload (one ending in '\n' would too — it's just cleaner - // to keep the fixture terminator-free, mirroring the buffer convention). - let payload = format!("typoena spike 3\n{BUILD_TAG}\ndedicated SPI3: SCK14 MOSI15 MISO13 CS10"); + // End the payload with '\n', like a real editor buffer (whose visible trailing + // blank line is exactly that terminating newline). `save` inserts a final + // newline only when one is missing and `load` reads verbatim, so a payload that + // already ends in '\n' round-trips byte-for-byte — which this exact-equality + // check relies on. + let payload = format!("typoena spike 3\n{BUILD_TAG}\ndedicated SPI3: SCK14 MOSI15 MISO13 CS10\n"); storage.save(&payload).context("Storage::save")?; let back = storage.load().context("Storage::load after save")?; if back != payload { diff --git a/firmware/src/persistence.rs b/firmware/src/persistence.rs index 66ede14..942e807 100644 --- a/firmware/src/persistence.rs +++ b/firmware/src/persistence.rs @@ -269,21 +269,13 @@ impl Storage { m.len() / 1024, MAX_FILE_BYTES / 1024 ), - Ok(_) => { - let mut s = - fs::read_to_string(path).with_context(|| format!("reading {path}"))?; - // Drop the single POSIX line terminator that `save_path` adds so the - // editor buffer stays newline-free (it counts rows as `#\n + 1`, so a - // trailing '\n' would render a phantom blank last line). Also handles a - // file authored elsewhere: it loads without that phantom line. - if s.ends_with('\n') { - s.pop(); - if s.ends_with('\r') { - s.pop(); // tolerate a CRLF terminator - } - } - Ok(s) - } + // Read the file verbatim. The editor's `rows = #\n + 1` model renders a + // trailing '\n' as an empty last line, and we *want* that: a note ends + // with a visible blank line that reflects its POSIX terminator. Since + // `save_path` guarantees that terminator, this load and that save form an + // identity round-trip for any device-written file (which always ends in + // '\n') — no strip needed, and none wanted. + Ok(_) => fs::read_to_string(path).with_context(|| format!("reading {path}")), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), Err(e) => Err(e).with_context(|| format!("stat {path}")), } @@ -308,17 +300,16 @@ impl Storage { .with_context(|| format!("create {tmp} (does its directory exist?)"))?; f.write_all(contents.as_bytes()) .with_context(|| format!("write {tmp}"))?; - // Append exactly one POSIX terminator, unconditionally — the symmetric - // inverse of `load_path`, which always strips one back off. This keeps - // git from flagging "No newline at end of file" and makes the buffer - // round-trip byte-for-byte: a buffer that ends in '\n' (a trailing blank - // line the writer left) becomes "…\n\n" on disk, so that blank line - // survives the next load instead of being swallowed. Everything handed - // to `save_path` is content *without* its terminator by convention (the - // editor buffer is newline-free, and `Prefs::to_toml` omits its trailing - // newline for the same reason), so this never double-terminates. - f.write_all(b"\n") - .with_context(|| format!("write final newline to {tmp}"))?; + // Insert a final newline only if the buffer lacks one (POSIX text + // convention; keeps git from flagging "No newline at end of file"). + // `load_path` reads verbatim, so this is the sole place the terminator is + // guaranteed — and because it's guarded, the file mirrors the buffer's + // trailing newlines exactly: one visible trailing blank line stays one, + // never doubled. A buffer that already ends in '\n' passes through as-is. + if !contents.ends_with('\n') { + f.write_all(b"\n") + .with_context(|| format!("write final newline to {tmp}"))?; + } // FatFS f_sync — flush the tmp fully before it can replace the target. f.sync_all().with_context(|| format!("fsync {tmp}"))?; }