Files
Marcos Paulo bb7f8ae79d
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Linux/Windows feedback, round 3 (trash, composer UI, search sort, reading mode, indent, table helpers, user themes)
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>
2026-10-19 14:17:25 -03:00

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();
}