bb7f8ae79d
Explorer: foldable calendar, hand-laid toolbar, no empty Bookmarks block, Move to... picker, folder notes, Trash tab. Menu bar as flat text, title bar drawn whole each frame. New Outline pane, scrolling Related tab, performance overlay (fps, frame time, memory, CPU), first-run UI scale from the desktop on Linux. Editor: Extract to a new note, Merge into..., reading mode, indent settings, table row/column controls, tags in notes searchable, search order and collapse, excluded search folders, user themes in the settings folder. Engine: note.extract, note.merge, trash.list/restore/empty. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
221 lines
9.2 KiB
Rust
221 lines
9.2 KiB
Rust
//! File operations through `craft-atomic`: read, write, rename, trash, list (SPEC §4.1).
|
|
|
|
mod common;
|
|
|
|
use std::fs;
|
|
|
|
use notescraft_vault::{MAX_READ_BYTES, Vault, WriteOutcome};
|
|
|
|
#[test]
|
|
fn write_creates_parent_folders_and_read_returns_the_bytes() {
|
|
let (_dir, vault) = common::vault("write");
|
|
assert_eq!(vault.write("sub/deep/note.md", b"# hello").unwrap(), WriteOutcome::Written);
|
|
assert_eq!(vault.read("sub/deep/note.md").unwrap(), b"# hello");
|
|
assert_eq!(fs::read(vault.root().join("sub/deep/note.md")).unwrap(), b"# hello");
|
|
}
|
|
|
|
#[test]
|
|
fn a_save_with_identical_bytes_is_unchanged() {
|
|
let (_dir, vault) = common::vault("unchanged");
|
|
assert_eq!(vault.write("note.md", b"same").unwrap(), WriteOutcome::Written);
|
|
let before = fs::metadata(vault.root().join("note.md")).unwrap().modified().unwrap();
|
|
assert_eq!(vault.write("note.md", b"same").unwrap(), WriteOutcome::Unchanged);
|
|
assert_eq!(fs::metadata(vault.root().join("note.md")).unwrap().modified().unwrap(), before);
|
|
assert_eq!(vault.read("note.md").unwrap(), b"same");
|
|
assert_eq!(vault.write("note.md", b"other").unwrap(), WriteOutcome::Written);
|
|
assert_eq!(vault.read("note.md").unwrap(), b"other");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_failed_atomic_write_leaves_the_old_file_intact() {
|
|
use std::fs::Permissions;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let (dir, vault) = common::vault("readonly-dir");
|
|
fs::create_dir(dir.path().join("locked")).unwrap();
|
|
vault.write("locked/note.md", b"original").unwrap();
|
|
|
|
fs::set_permissions(dir.path().join("locked"), Permissions::from_mode(0o555)).unwrap();
|
|
// A process that can write anyway (root) cannot observe the failure; skip instead of lie.
|
|
if fs::OpenOptions::new().write(true).create_new(true).open(dir.path().join("locked/probe")).is_ok() {
|
|
fs::set_permissions(dir.path().join("locked"), Permissions::from_mode(0o755)).unwrap();
|
|
return;
|
|
}
|
|
|
|
let err = vault.write("locked/note.md", b"replacement").expect_err("write into a read-only folder must fail");
|
|
assert!(matches!(err, notescraft_vault::Error::Io { .. }), "{err:?}");
|
|
assert_eq!(vault.read("locked/note.md").unwrap(), b"original");
|
|
|
|
// The staging file is cleaned up.
|
|
fs::set_permissions(dir.path().join("locked"), Permissions::from_mode(0o755)).unwrap();
|
|
let leftovers: Vec<String> =
|
|
fs::read_dir(dir.path().join("locked")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n != "note.md").collect();
|
|
assert!(leftovers.is_empty(), "leftovers: {leftovers:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_over_the_read_limit_is_rejected() {
|
|
let (_dir, vault) = common::vault("too-large");
|
|
let big = vec![b'x'; usize::try_from(MAX_READ_BYTES).unwrap() + 1];
|
|
fs::write(vault.root().join("big.bin"), &big).unwrap();
|
|
let err = vault.read("big.bin").expect_err("over the limit");
|
|
assert!(matches!(err, notescraft_vault::Error::TooLarge { .. }), "{err:?}");
|
|
fs::remove_file(vault.root().join("big.bin")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rename_moves_the_file() {
|
|
let (_dir, vault) = common::vault("rename");
|
|
vault.write("old/name.md", b"body").unwrap();
|
|
vault.rename("old/name.md", "new/name2.md").unwrap();
|
|
assert!(vault.read("old/name.md").is_err());
|
|
assert_eq!(vault.read("new/name2.md").unwrap(), b"body");
|
|
}
|
|
|
|
#[test]
|
|
fn create_dir_makes_folders() {
|
|
let (_dir, vault) = common::vault("mkdir");
|
|
vault.create_dir("a/b/c").unwrap();
|
|
assert!(vault.root().join("a/b/c").is_dir());
|
|
vault.create_dir("a/b/c").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn delete_moves_the_file_into_the_vault_trash() {
|
|
let (_dir, vault) = common::vault("trash");
|
|
vault.write("gone.md", b"body").unwrap();
|
|
let trashed = vault.delete_to_trash("gone.md").unwrap();
|
|
assert!(trashed.starts_with(vault.root().join(".notescraft/trash")), "{trashed:?}");
|
|
assert_eq!(fs::read(&trashed).unwrap(), b"body");
|
|
assert!(!vault.root().join("gone.md").exists());
|
|
assert!(vault.read("gone.md").is_err());
|
|
|
|
// The trash keeps the file, and a second delete of the same name does not clobber it.
|
|
vault.write("gone.md", b"again").unwrap();
|
|
let second = vault.delete_to_trash("gone.md").unwrap();
|
|
assert_ne!(second, trashed);
|
|
assert_eq!(fs::read(&trashed).unwrap(), b"body");
|
|
assert_eq!(fs::read(&second).unwrap(), b"again");
|
|
}
|
|
|
|
#[test]
|
|
fn list_is_sorted_and_skips_hidden_folders() {
|
|
let (_dir, vault) = common::vault("list");
|
|
for rel in ["b.md", "a.md", "sub/c.md", "sub/deep/d.md", ".hidden/e.md", ".dotfile.md"] {
|
|
vault.write(rel, b"x").unwrap();
|
|
}
|
|
vault.create_dir("empty").unwrap();
|
|
|
|
let paths: Vec<(String, bool)> = vault.list().unwrap().into_iter().map(|e| (e.path, e.is_dir)).collect();
|
|
assert_eq!(
|
|
paths,
|
|
vec![
|
|
("a.md".to_string(), false),
|
|
("b.md".to_string(), false),
|
|
("empty".to_string(), true),
|
|
("sub".to_string(), true),
|
|
("sub/c.md".to_string(), false),
|
|
("sub/deep".to_string(), true),
|
|
("sub/deep/d.md".to_string(), false),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn list_does_not_follow_symlinks() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let (dir, vault) = common::vault("list-links");
|
|
let outside = common::TempDir::new("list-outside");
|
|
fs::write(outside.path().join("secret.md"), b"s").unwrap();
|
|
fs::create_dir(dir.path().join("sub")).unwrap();
|
|
symlink(outside.path(), dir.path().join("sub/escape")).unwrap();
|
|
symlink(outside.path().join("secret.md"), dir.path().join("link.md")).unwrap();
|
|
|
|
let paths: Vec<String> = vault.list().unwrap().into_iter().map(|e| e.path).collect();
|
|
assert_eq!(paths, vec!["sub".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_vault_refuses_writes() {
|
|
let dir = common::TempDir::new("read-only");
|
|
let vault = Vault::open_read_only(dir.path()).unwrap();
|
|
assert!(vault.is_read_only());
|
|
fs::write(vault.root().join("note.md"), b"x").unwrap();
|
|
assert_eq!(vault.read("note.md").unwrap(), b"x");
|
|
for err in
|
|
[vault.write("note.md", b"y").map(|_| ()), vault.rename("note.md", "other.md"), vault.delete_to_trash("note.md").map(|_| ()), vault.create_dir("dir")]
|
|
{
|
|
assert!(matches!(err, Err(notescraft_vault::Error::ReadOnly)), "{err:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn list_reports_size_and_modified_time_of_files() {
|
|
let (_dir, vault) = common::vault("list-meta");
|
|
vault.write("note.md", b"12345").unwrap();
|
|
vault.create_dir("sub").unwrap();
|
|
|
|
let entries = vault.list().unwrap();
|
|
let note = entries.iter().find(|e| e.path == "note.md").unwrap();
|
|
assert_eq!(note.size, 5);
|
|
let disk = fs::metadata(vault.root().join("note.md")).unwrap().modified().unwrap();
|
|
let expected = disk.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis();
|
|
assert_eq!(note.modified.map(u128::from), Some(expected));
|
|
let folder = entries.iter().find(|e| e.path == "sub").unwrap();
|
|
assert_eq!((folder.size, folder.modified), (0, None));
|
|
}
|
|
|
|
#[test]
|
|
fn permanent_delete_leaves_no_trash_copy_and_refuses_reserved_paths() {
|
|
let (_dir, vault) = common::vault("permanent");
|
|
vault.write("gone.md", b"x").unwrap();
|
|
vault.delete_permanently("gone.md").unwrap();
|
|
assert!(!vault.root().join("gone.md").exists());
|
|
assert!(!vault.root().join(".notescraft/trash").exists(), "no copy kept");
|
|
assert!(vault.delete_permanently(".obsidian/app.json").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn list_with_hidden_adds_dot_entries_but_never_the_private_folders() {
|
|
let (_dir, vault) = common::vault("hidden-list");
|
|
vault.write("shown.md", b"x").unwrap();
|
|
fs::create_dir_all(vault.root().join(".secret")).unwrap();
|
|
fs::write(vault.root().join(".secret/note.md"), "y").unwrap();
|
|
fs::create_dir_all(vault.root().join(".git")).unwrap();
|
|
let paths = |entries: Vec<notescraft_vault::VaultEntry>| entries.into_iter().map(|e| e.path).collect::<Vec<_>>();
|
|
assert_eq!(paths(vault.list().unwrap()), ["shown.md"]);
|
|
assert_eq!(paths(vault.list_with_hidden().unwrap()), [".secret", ".secret/note.md", "shown.md"]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_trash_lists_restores_and_empties() {
|
|
let (_dir, vault) = common::vault("trash-restore");
|
|
vault.write("notes/a.md", b"alpha").unwrap();
|
|
vault.write("b.md", b"beta").unwrap();
|
|
vault.delete_to_trash("notes/a.md").unwrap();
|
|
vault.delete_to_trash("b.md").unwrap();
|
|
let listed = vault.trash_list().unwrap();
|
|
assert_eq!(listed.len(), 2);
|
|
let a = listed.iter().find(|entry| entry.name == "a.md").unwrap();
|
|
assert_eq!(a.from.as_deref(), Some("notes/a.md"));
|
|
assert_eq!(vault.restore_from_trash(&a.id).unwrap(), "notes/a.md");
|
|
assert_eq!(vault.read("notes/a.md").unwrap(), b"alpha");
|
|
assert_eq!(vault.trash_list().unwrap().len(), 1);
|
|
|
|
// Nothing is overwritten: a note made at the old place since blocks the restore.
|
|
vault.write("b.md", b"new b").unwrap();
|
|
let b = vault.trash_list().unwrap().remove(0);
|
|
assert!(vault.restore_from_trash(&b.id).is_err());
|
|
assert_eq!(vault.read("b.md").unwrap(), b"new b");
|
|
|
|
for hostile in ["", "x", "../x", "12/../../etc", "12/a/b", "ab/c", "12/.from", "12/a.md.from", "12/"] {
|
|
assert!(vault.restore_from_trash(hostile).is_err(), "{hostile:?}");
|
|
}
|
|
vault.empty_trash().unwrap();
|
|
assert_eq!(vault.trash_list().unwrap().len(), 0);
|
|
vault.empty_trash().unwrap();
|
|
}
|