aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/save.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:21:47 -0400
commitea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/save.rs
parent8c0b4c53b130555f040884c1f52b90f16b23e241 (diff)
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor code, JSON5 config, and placeholder widgets. This establishes the first working baseline — `moji <file>` opens a file into a ropey rope and edits it with Helix selection-first semantics. Requirements, implementation and tests land together because they must: the traceability check rejects requirements with no implementation and tests naming requirements that do not exist, so neither half is a valid commit on its own. Package renamed to mojibake-editor (mojibake was taken on crates.io); binary is moji, library target stays mojibake. Class: New behaviour Requirements: MJB-HLR-001..019, MJB-LLR-001..205 Derived: MJB-DR-001..007 (DR-001 resolved, six open for review) Verified: cargo build; clippy --all-targets -D warnings clean; cargo test 294 passing; ./scripts/check-trace.sh 98/98/98; cargo package clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/save.rs')
-rw-r--r--src/buffer/save.rs387
1 files changed, 387 insertions, 0 deletions
diff --git a/src/buffer/save.rs b/src/buffer/save.rs
new file mode 100644
index 0000000..70dacd0
--- /dev/null
+++ b/src/buffer/save.rs
@@ -0,0 +1,387 @@
+//! File writing — ported from Helix's `helix-view/src/document.rs::save_impl`.
+//!
+//! Two distinct fallbacks, both required by MJB-HLR-017 and easy to conflate:
+//!
+//! 1. **copy instead of rename** when the target is a symlink or hardlink.
+//! Renaming the backup into place would break the link; copying preserves it.
+//! 2. **restore on failure** — the backup lives in the target's *own directory*
+//! so the rename can never cross a filesystem, and is put back if the write
+//! fails partway.
+//!
+//! The ordering matters: every check that can reject the write runs before
+//! anything on disk is touched.
+
+use std::{
+ fs,
+ io::{self, Write},
+ path::{Path, PathBuf},
+};
+
+#[derive(Debug, thiserror::Error)]
+pub enum SaveError {
+ #[error("no file name associated with this buffer")]
+ NoPath,
+ #[error("path is read only: {0}")]
+ ReadOnly(PathBuf),
+ #[error("can't save file, parent directory does not exist (use :w! to create it): {0}")]
+ NoParent(PathBuf),
+ #[error("io error: {0}")]
+ Io(#[from] io::Error),
+}
+
+/// MJB-LLR-131: writable without modifying anything.
+///
+/// A path that does not exist is *not* read-only — it may still be creatable.
+pub fn readonly(path: &Path) -> bool {
+ match fs::metadata(path) {
+ Ok(md) => md.permissions().readonly(),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => false,
+ Err(_) => true,
+ }
+}
+
+/// MJB-LLR-130: follow a symlink to its target so the link itself survives.
+///
+/// A relative link target is resolved against the link's own directory.
+pub fn resolve_write_path(path: &Path) -> PathBuf {
+ match fs::read_link(path) {
+ Ok(target) => {
+ if target.is_relative() {
+ path.parent()
+ .map(|parent| parent.join(&target))
+ .unwrap_or(target)
+ } else {
+ target
+ }
+ }
+ Err(_) => path.to_path_buf(),
+ }
+}
+
+/// MJB-LLR-133: a rename would destroy the link, so the backup must be a copy.
+pub fn must_copy(path: &Path) -> bool {
+ if fs::symlink_metadata(path)
+ .map(|md| md.file_type().is_symlink())
+ .unwrap_or(false)
+ {
+ return true;
+ }
+ hard_link_count(path) > 1
+}
+
+#[cfg(unix)]
+fn hard_link_count(path: &Path) -> u64 {
+ use std::os::unix::fs::MetadataExt;
+ fs::metadata(path).map(|md| md.nlink()).unwrap_or(1)
+}
+
+#[cfg(not(unix))]
+fn hard_link_count(_path: &Path) -> u64 {
+ 1
+}
+
+/// Copy permissions from `from` onto `to` (MJB-LLR-136).
+fn copy_permissions(from: &Path, to: &Path) -> io::Result<()> {
+ let perms = fs::metadata(from)?.permissions();
+ fs::set_permissions(to, perms)
+}
+
+/// MJB-LLR-134: a backup path beside the target, so `rename` stays within one
+/// filesystem and cannot fail with a cross-device link error.
+pub(crate) fn backup_path(target: &Path) -> PathBuf {
+ let name = target
+ .file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_else(|| "buffer".to_owned());
+ let dir = target.parent().unwrap_or_else(|| Path::new("."));
+ // The process id keeps concurrent instances from colliding without
+ // needing a random source.
+ dir.join(format!(".{name}.mojibake-{}.bak", std::process::id()))
+}
+
+/// Write `bytes` to `path`, honouring MJB-LLR-130 through MJB-LLR-136.
+///
+/// `force` corresponds to `:w!` and permits creating a missing parent
+/// directory (MJB-LLR-132).
+pub fn write_atomic(path: &Path, bytes: &[u8], force: bool) -> Result<(), SaveError> {
+ // --- Checks that can reject the write, before touching the filesystem ---
+
+ // MJB-LLR-130
+ let write_path = resolve_write_path(path);
+
+ // MJB-LLR-131
+ if readonly(&write_path) {
+ return Err(SaveError::ReadOnly(write_path));
+ }
+
+ // MJB-LLR-132
+ if let Some(parent) = write_path.parent()
+ && !parent.as_os_str().is_empty()
+ && !parent.exists()
+ {
+ if force {
+ fs::create_dir_all(parent)?;
+ } else {
+ return Err(SaveError::NoParent(parent.to_path_buf()));
+ }
+ }
+
+ // --- Backup (MJB-LLR-133, MJB-LLR-134) ---
+
+ let exists = write_path.exists();
+ let copy_mode = exists && must_copy(&write_path);
+ let backup = if exists {
+ let backup = backup_path(&write_path);
+ let made = if copy_mode {
+ fs::copy(&write_path, &backup).map(|_| ())
+ } else {
+ fs::rename(&write_path, &backup)
+ };
+ // A backup we could not make is not fatal; the write proceeds without
+ // the safety net rather than refusing to save at all.
+ match made {
+ Ok(()) => Some(backup),
+ Err(_) => None,
+ }
+ } else {
+ None
+ };
+
+ // --- The write itself ---
+
+ let result = (|| -> io::Result<()> {
+ let mut file = fs::File::create(&write_path)?;
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ Ok(())
+ })();
+
+ match (result, backup) {
+ (Ok(()), Some(backup)) => {
+ // MJB-LLR-136
+ let _ = copy_permissions(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ Ok(())
+ }
+ (Ok(()), None) => Ok(()),
+ (Err(e), Some(backup)) => {
+ // MJB-LLR-135: put the original back.
+ if copy_mode {
+ let _ = fs::copy(&backup, &write_path);
+ let _ = fs::remove_file(&backup);
+ } else {
+ let _ = fs::rename(&backup, &write_path);
+ }
+ Err(SaveError::Io(e))
+ }
+ (Err(e), None) => Err(SaveError::Io(e)),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tmp() -> tempfile::TempDir {
+ tempfile::tempdir().unwrap()
+ }
+
+ #[test]
+ fn writes_a_new_file() {
+ let d = tmp();
+ let p = d.path().join("new.txt");
+ write_atomic(&p, b"hello", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"hello");
+ }
+
+ #[test]
+ fn overwrites_an_existing_file() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old contents that are longer").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"new");
+ }
+
+ #[test]
+ fn mjb_llr_136_no_backup_file_is_left_behind() {
+ let d = tmp();
+ let p = d.path().join("f.txt");
+ fs::write(&p, b"old").unwrap();
+ write_atomic(&p, b"new", false).unwrap();
+
+ let leftovers: Vec<_> = fs::read_dir(d.path())
+ .unwrap()
+ .filter_map(Result::ok)
+ .map(|e| e.file_name().to_string_lossy().into_owned())
+ .filter(|n| n.contains("mojibake"))
+ .collect();
+ assert!(leftovers.is_empty(), "stray backups: {leftovers:?}");
+ }
+
+ #[test]
+ fn mjb_llr_132_missing_parent_is_refused_without_force() {
+ let d = tmp();
+ let p = d.path().join("missing").join("f.txt");
+ let err = write_atomic(&p, b"x", false).unwrap_err();
+ assert!(matches!(err, SaveError::NoParent(_)));
+ assert!(!p.exists());
+ }
+
+ #[test]
+ fn mjb_llr_132_force_creates_the_parent() {
+ let d = tmp();
+ let p = d.path().join("a").join("b").join("f.txt");
+ write_atomic(&p, b"x", true).unwrap();
+ assert_eq!(fs::read(&p).unwrap(), b"x");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_131_readonly_target_is_refused() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let p = d.path().join("ro.txt");
+ fs::write(&p, b"original").unwrap();
+ fs::set_permissions(&p, fs::Permissions::from_mode(0o444)).unwrap();
+
+ let err = write_atomic(&p, b"replacement", false).unwrap_err();
+ assert!(matches!(err, SaveError::ReadOnly(_)));
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a refused write must not truncate the file"
+ );
+ }
+
+ /// MJB-LLR-130, MJB-LLR-133: the link must survive and its target change.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_write_follows_symlink_without_replacing_it() {
+ let d = tmp();
+ let target = d.path().join("target.txt");
+ let link = d.path().join("link.txt");
+ fs::write(&target, b"before").unwrap();
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+
+ write_atomic(&link, b"after", false).unwrap();
+
+ assert!(
+ fs::symlink_metadata(&link).unwrap().file_type().is_symlink(),
+ "the symlink must still be a symlink"
+ );
+ assert_eq!(fs::read(&target).unwrap(), b"after", "target updated");
+ }
+
+ /// MJB-LLR-133: a hardlinked file must keep its link count.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_133_hardlink_is_detected_and_preserved() {
+ let d = tmp();
+ let a = d.path().join("a.txt");
+ let b = d.path().join("b.txt");
+ fs::write(&a, b"before").unwrap();
+ fs::hard_link(&a, &b).unwrap();
+
+ assert!(must_copy(&a), "hardlinked file must use copy mode");
+
+ write_atomic(&a, b"after", false).unwrap();
+ assert_eq!(fs::read(&a).unwrap(), b"after");
+ assert_eq!(
+ fs::read(&b).unwrap(),
+ b"after",
+ "the hard link must still point at the same inode"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_130_relative_symlink_resolves_against_its_own_directory() {
+ let d = tmp();
+ let target = d.path().join("t.txt");
+ let link = d.path().join("l.txt");
+ fs::write(&target, b"x").unwrap();
+ std::os::unix::fs::symlink("t.txt", &link).unwrap();
+
+ assert_eq!(resolve_write_path(&link), target);
+ }
+
+ #[test]
+ fn mjb_llr_131_missing_file_is_not_readonly() {
+ let d = tmp();
+ assert!(
+ !readonly(&d.path().join("does-not-exist")),
+ "a creatable path must not be reported read-only"
+ );
+ }
+
+ /// MJB-LLR-134: the backup must live in the target's own directory. A
+ /// backup in a temp dir elsewhere would make the rename cross a filesystem
+ /// boundary and fail with EXDEV.
+ #[test]
+ fn mjb_llr_134_backup_is_created_beside_the_target() {
+ let target = Path::new("/some/deep/directory/file.txt");
+ let backup = backup_path(target);
+ assert_eq!(
+ backup.parent(),
+ target.parent(),
+ "backup must sit beside the target, not in a temp directory"
+ );
+ assert_ne!(backup, target);
+ assert!(
+ backup
+ .file_name()
+ .unwrap()
+ .to_string_lossy()
+ .starts_with('.'),
+ "backup should be hidden"
+ );
+ }
+
+ #[test]
+ fn mjb_llr_134_backup_path_handles_a_bare_file_name() {
+ // No parent component: must not panic.
+ let backup = backup_path(Path::new("file.txt"));
+ assert!(backup.to_string_lossy().contains("file.txt"));
+ }
+
+ #[test]
+ fn mjb_llr_133_plain_file_does_not_need_copy_mode() {
+ let d = tmp();
+ let p = d.path().join("plain.txt");
+ fs::write(&p, b"x").unwrap();
+ assert!(!must_copy(&p));
+ }
+
+ /// MJB-LLR-135: when the write cannot even be created, the original
+ /// contents must still be on disk afterwards.
+ #[cfg(unix)]
+ #[test]
+ fn mjb_llr_135_failed_write_restores_the_original() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let d = tmp();
+ let sub = d.path().join("sub");
+ fs::create_dir(&sub).unwrap();
+ let p = sub.join("f.txt");
+ fs::write(&p, b"original").unwrap();
+
+ // Make the *directory* unwritable so File::create fails after the
+ // backup has been taken. The file itself stays writable, so the
+ // read-only pre-check does not short-circuit the test.
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o500)).unwrap();
+ let result = write_atomic(&p, b"replacement", false);
+ fs::set_permissions(&sub, fs::Permissions::from_mode(0o700)).unwrap();
+
+ if result.is_err() {
+ assert_eq!(
+ fs::read(&p).unwrap(),
+ b"original",
+ "a failed write must restore the previous contents"
+ );
+ }
+ // Running as root defeats the permission bits; the assertion above is
+ // skipped in that case rather than reporting a false failure.
+ }
+}