use super::*;
use crate::editor::namespace::{StoreDispatch, StorePrepared};
use strop_core::Buffer;
use strop_workspace::operation::{OperationIntent, OperationKind, StepOutcome, StepReceipt};
fn store_intent(editor: &Editor, path: &std::path::Path) -> OperationIntent {
use sha2::Digest;
let buffer = &editor.buf();
let mut bytes = Vec::new();
for chunk in buffer.snapshot().chunks() {
bytes.extend_from_slice(chunk.as_bytes());
}
let digest: [u8; 32] = sha2::Sha256::digest(&bytes).into();
let baseline = buffer.disk_stamp().map(super::open::systemtime_to_filetime);
OperationIntent {
kind: OperationKind::Store,
source: None,
destination: Some(strop_workspace::ResourceLocation::local(path.to_path_buf())),
copy_version: strop_workspace::operation::CopyVersion::Stored,
expected_content: Some(digest),
store: Some(strop_workspace::operation::StorePolicy {
baseline,
baseline_object: None,
baseline_attributes: None,
force: false,
expect_absent: false,
displayed: None,
}),
}
}
fn inject_unconfirmed(
editor: &mut Editor,
document: DocumentId,
path: &std::path::Path,
) -> StorePrepared {
let dispatch = StoreDispatch::Local(editor.filesystem.worker().clone());
let (token, _handle) = strop_core::worker::CancelToken::standalone();
let operation = dispatch
.prepare_store(store_intent(editor, path), &token)
.expect("the store prepares against the real worker");
let attempt = StoreAttempt {
revision: editor.buf().revision(),
origin: editor.buf().path.clone(),
target: path.to_path_buf(),
write_target: path.to_path_buf(),
close: false,
force: false,
focus: editor.focus_epoch,
namespace: editor.filesystem.worker().namespace().unwrap(),
receipt: StepReceipt {
step: 0,
operation: operation.operation.clone(),
outcome: StepOutcome::Unconfirmed {
detail: "simulated lost receipt".into(),
observed_destination: None,
recovery: None,
publication: None,
},
},
};
editor.io.store_attempts.insert(document, attempt);
operation
}
#[test]
fn local_save_rides_the_worker_store_intent() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("note.txt");
std::fs::write(&path, "before\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor.open_fixture(&path).unwrap();
editor.feed_text("Oafter<esc>");
assert!(editor.buf().dirty);
assert!(editor.request_save_document(document, None, false, false));
editor.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "after\nbefore\n");
assert!(
!editor.buf().dirty,
"the committed store retires the revision"
);
assert_eq!(editor.message, "written");
assert!(editor.filesystem.worker().session().is_some());
}
#[test]
fn stale_prepared_store_cannot_publish_after_worker_respawns() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("note.txt");
std::fs::write(&path, "before\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
editor.open_fixture(&path).unwrap();
editor.feed_text("Oafter<esc>");
let worker = editor.filesystem.worker().clone();
let dispatch = StoreDispatch::Local(worker.clone());
let (token, _handle) = strop_core::worker::CancelToken::standalone();
let stale = dispatch
.prepare_store(store_intent(&editor, &path), &token)
.unwrap();
let old_session = stale.session;
worker.shutdown().unwrap();
worker.health(&token).unwrap();
assert_ne!(worker.session(), Some(old_session));
match dispatch.apply_store(stale, b"after\nbefore\n", &token) {
crate::editor::namespace::StoreOutcome::Refused(error) => {
assert_eq!(
error.kind,
strop_workspace::operation::FsFailureKind::Conflict
);
}
crate::editor::namespace::StoreOutcome::Receipt(receipt) => {
panic!("stale Store must refuse before publication: {receipt:?}");
}
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), "before\n");
let fresh = dispatch
.prepare_store(store_intent(&editor, &path), &token)
.unwrap();
match dispatch.apply_store(fresh, b"after\nbefore\n", &token) {
crate::editor::namespace::StoreOutcome::Receipt(receipt) => {
assert!(receipt.outcome.is_committed(), "{receipt:?}");
}
crate::editor::namespace::StoreOutcome::Refused(error) => {
panic!("new-session Store should commit: {error}");
}
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), "after\nbefore\n");
worker.shutdown().unwrap();
}
#[test]
fn save_conflict_on_a_moved_baseline_and_force_overwrites() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("note.txt");
std::fs::write(&path, "before\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor.open_fixture(&path).unwrap();
editor.feed_text("Omine<esc>");
std::fs::write(&path, "theirs\n").unwrap();
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_times(
std::fs::FileTimes::new()
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(42)),
)
.unwrap();
assert!(editor.request_save_document(document, None, false, false));
editor.wait_io().unwrap();
assert!(
editor.message.contains("file changed on disk"),
"typed conflict, dirty preserved: {}",
editor.message
);
assert!(editor.buf().dirty);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "theirs\n");
assert!(editor.request_save_document(document, None, true, false));
editor.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "mine\nbefore\n");
assert!(!editor.buf().dirty);
}
#[test]
fn save_as_refuses_an_occupied_name_unless_forced() {
let directory = tempfile::tempdir().unwrap();
let occupied = directory.path().join("occupied.txt");
std::fs::write(&occupied, "keep\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor
.open_fixture(&directory.path().join("note.txt"))
.unwrap();
editor.feed_text("Odata<esc>");
assert!(editor.request_save_document(document, Some(occupied.clone()), false, false));
editor.wait_io().unwrap();
assert!(
editor.message.contains("file exists"),
"occupied save-as refuses: {}",
editor.message
);
assert_eq!(std::fs::read_to_string(&occupied).unwrap(), "keep\n");
assert!(editor.request_save_document(document, Some(occupied.clone()), true, false));
editor.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&occupied).unwrap(), "data\n");
assert_eq!(
editor.buf().path.as_deref(),
Some(occupied.as_path()),
"a committed save-as rebinds the document"
);
}
#[cfg(unix)]
#[test]
fn save_preserves_permissions_through_the_atomic_replace() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("locked.txt");
std::fs::write(&path, "before\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor.open_fixture(&path).unwrap();
editor.feed_text("Oafter<esc>");
assert!(editor.request_save_document(document, None, false, false));
editor.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "after\nbefore\n");
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o7777,
0o640,
"permissions survive the worker store"
);
}
#[test]
fn same_size_content_change_commits_and_verifies() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("note.txt");
std::fs::write(&path, "aaaa\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor.open_fixture(&path).unwrap();
editor.feed_text("ccbbbb<esc>");
assert!(editor.request_save_document(document, None, false, false));
editor.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "bbbb\n");
assert!(!editor.buf().dirty);
}
#[test]
fn unconfirmed_save_verifies_before_any_rewrite() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("note.txt");
std::fs::write(&path, "before\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor.open_fixture(&path).unwrap();
editor.feed_text("Omine<esc>");
let _ = inject_unconfirmed(&mut editor, document, &path);
assert!(editor.request_save_document(document, None, false, false));
editor.wait_io().unwrap();
assert_eq!(
editor.message, "the original is unchanged on disk; :w writes again",
"verification reconciles without writing"
);
assert!(editor.buf().dirty);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "before\n");
assert!(
editor.io.store_attempts.is_empty(),
"a verified-unchanged attempt clears"
);
assert!(editor.request_save_document(document, None, false, false));
editor.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "mine\nbefore\n");
assert!(!editor.buf().dirty);
}
#[test]
fn a_late_committed_store_reconciles_the_source_once() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("note.txt");
std::fs::write(&path, "before\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor.open_fixture(&path).unwrap();
editor.feed_text("Omine<esc>");
let operation = inject_unconfirmed(&mut editor, document, &path);
let dispatch = StoreDispatch::Local(editor.filesystem.worker().clone());
let (token, _handle) = strop_core::worker::CancelToken::standalone();
match dispatch.apply_store(operation, b"mine\nbefore\n", &token) {
crate::editor::namespace::StoreOutcome::Receipt(receipt) => {
assert!(matches!(receipt.outcome, StepOutcome::Committed { .. }));
}
crate::editor::namespace::StoreOutcome::Refused(error) => {
panic!("native Store must land before its receipt is lost: {error}");
}
}
assert!(editor.request_save_document(document, None, false, false));
editor.wait_io().unwrap();
assert_eq!(
editor.message, "written",
"verified committed: {}",
editor.message
);
assert!(
!editor.buf().dirty,
"the late committed receipt retires the saved revision"
);
assert!(editor.io.store_attempts.is_empty());
}
#[test]
fn a_forced_rewrite_cannot_bypass_an_unverified_outcome() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("note.txt");
std::fs::write(&path, "before\n").unwrap();
let mut editor = Editor::new(Buffer::from_text(""));
let document = editor.open_fixture(&path).unwrap();
editor.feed_text("Omine<esc>");
let _ = inject_unconfirmed(&mut editor, document, &path);
assert!(editor.request_save_document(document, None, true, false));
editor.wait_io().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "before\n");
assert!(editor.buf().dirty);
}