feature fixes

This commit is contained in:
pj committed 2026-08-28 16:08:39 +05:30
1 parent 1b31e2f189
commit 586ee946d0
38 files changed
+1938 -260

No files matched your search

+115 -10
View File
@@ -20,7 +20,7 @@ use std::sync::atomic::{AtomicU64, Ordering as Memory};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use ignore::WalkBuilder;
use ignore::{DirEntry, WalkBuilder};
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
@@ -387,6 +387,27 @@ fn assemble(
Some(node)
}
/// True for everything a walk should keep, which is everything except one of the four always
/// skipped names turning up as a folder somewhere below the walk root. Returning false for a
/// directory prunes it, so nothing inside it is walked either.
///
/// The root itself is kept whatever it is called, because a user who opens a folder named `dist`
/// opened it deliberately and hiding its entire contents from them would be absurd. Files are kept
/// whatever they are called too: the four names describe folders, and a document called `target.md`
/// is a document.
///
/// Shared by every walk in the app rather than written out once per walk, so the sidebar, the index
/// and the link sweep cannot drift apart about which folders are never worth descending into.
fn not_always_skipped(entry: &DirEntry) -> bool {
if entry.depth() == 0 {
return true;
}
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
return true;
}
!ALWAYS_SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref())
}
/// One pass over a folder, returning the root node with everything under it already attached.
///
/// `show_ignored` turns off gitignore, the hidden file rule and the four always skipped folders in
@@ -407,15 +428,7 @@ pub fn scan_tree(root: &Path, show_ignored: bool) -> Result<FileNode, String> {
.require_git(false)
.standard_filters(!show_ignored);
if !show_ignored {
builder.filter_entry(|entry| {
if entry.depth() == 0 {
return true;
}
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
return true;
}
!ALWAYS_SKIPPED.contains(&entry.file_name().to_string_lossy().as_ref())
});
builder.filter_entry(not_always_skipped);
}
let mut nodes: HashMap<PathBuf, FileNode> = HashMap::new();
@@ -445,6 +458,73 @@ pub fn scan_tree(root: &Path, show_ignored: bool) -> Result<FileNode, String> {
assemble(root, &mut nodes, &children).ok_or_else(|| format!("cannot read {}", root.display()))
}
/// Every markdown document under `root`, as flat paths, walked by the link sweep's rules rather
/// than the sidebar's.
///
/// This walk exists because a .gitignore is a statement about version control and not about whether
/// a file is a document. The tree and the index honour it, and they are right to: a sidebar full of
/// build output and a search box full of vendored READMEs are both worse than those files staying
/// out of sight, and neither of them changes anything by leaving a file alone. A link is the
/// opposite case. A relative link inside an ignored draft is still a link the user follows, and
/// leaving it pointing at a path that this app is the thing that moved is a break nobody finds
/// until the day they click it. Hiding that file costs the user a broken document rather than a
/// tidy sidebar, so the sweep walks by its own rules and the two are allowed to disagree.
///
/// So the three git sources come off and the four hardcoded folders stay on, which is what keeps a
/// checkout's node_modules out of the sweep whether or not git was ever asked about it. Hidden
/// files stay out for the same reason, since a dotted folder is where other languages keep their
/// tooling and none of `.venv`, `.next`, `.cache`, `.tox` or `.gradle` holds a link anybody wrote.
/// A `.ignore` or `.rgignore` is still honoured, because that file is written for tools that walk
/// rather than for git, which makes it the honest way to tell this walk to stay out of a folder.
///
/// `limit` bounds the work, because the sweep reads and rewrites every file this returns and an
/// unbounded one over a folder the size of somebody's home directory is not what they asked for
/// when they renamed a file. There is deliberately no depth cap to go with it: a document one level
/// past a depth cap is silently not swept and nothing anywhere says so, which is the same class of
/// bug this function exists to fix. A count is honest instead, because the caller can see it was
/// hit. Which is why one path past the limit comes back rather than exactly `limit` of them: a
/// folder holding exactly the budget and a folder holding ten thousand more look identical at
/// `limit` paths, and the caller has to be able to tell them apart to say that its coverage was
/// partial rather than reporting a complete sweep of a subset.
pub fn documents_for_sweep(root: &Path, limit: usize) -> Vec<String> {
let mut builder = WalkBuilder::new(root);
builder
// A symlinked folder pointing back at one of its own ancestors would otherwise walk for
// ever, exactly as it would for the tree.
.follow_links(false)
.hidden(true)
// No climbing above the walk root looking for ignore files. The sweep is about this one
// folder, and what some parent of it happens to say about it is not this folder's business.
.parents(false)
.ignore(true)
.git_ignore(false)
.git_global(false)
.git_exclude(false);
builder.filter_entry(not_always_skipped);
let mut out = Vec::new();
for entry in builder.build() {
// One unreadable entry is one document the sweep does not visit and not a failed sweep. The
// caller reports what it covered either way, so losing a row here is a smaller and more
// honest failure than refusing to rewrite anything at all.
let Ok(entry) = entry else { continue };
if entry.file_type().map(|t| t.is_dir()).unwrap_or(true) {
continue;
}
// Markdown only. Plain text is indexed and searchable, but nothing in a .txt is a markdown
// link this app knows how to rewrite, and opening every one of them to find that out would
// be work spent to change nothing.
if kind_for(entry.path(), false) != "markdown" {
continue;
}
out.push(path_string(entry.path()));
if out.len() > limit {
break;
}
}
out
}
pub fn read_document(path: &Path) -> Result<ReadResult, String> {
// The mtime is taken before the read rather than after. Read the other way round and a change
// landing between the two would be stamped onto older text, and the next save would overwrite
@@ -756,6 +836,31 @@ pub fn tree_read(roots: State<'_, Roots>, root_id: String) -> Result<FileNode, S
scan_tree(Path::new(&path), false)
}
/// The flat list of markdown documents the link rewrite sweep has to visit in one root, at most
/// `limit` of them plus one more if the folder holds more than that.
///
/// This is deliberately not `tree_read`, and the difference is the whole point of it. The tree
/// hides what the folder's gitignore hides, which is the right answer for a sidebar and for search
/// because both of them only ever read: the worst a hidden row costs is a file the user has to find
/// another way. The sweep writes. A stale link left inside a file the tree chose not to show is
/// bytes on disk that look correct and are not, and the user learns about it by clicking the link
/// long after the rename that broke it. Reusing the tree's walk here would mean the app quietly
/// breaks the documents it decided were not worth showing, which is worse than either not renaming
/// or renaming loudly.
///
/// The count that comes back is the caller's, not this command's, business: a list longer than
/// `limit` means the folder overflowed the budget, and the caller is expected to say the sweep was
/// partial rather than rewrite the first `limit` files and report a finished job.
#[tauri::command(async)]
pub fn sweep_documents(
roots: State<'_, Roots>,
root_id: String,
limit: u32,
) -> Result<Vec<String>, String> {
let path = roots.path_for(&root_id)?;
Ok(documents_for_sweep(Path::new(&path), limit as usize))
}
/// Opens Finder with the file selected, rather than opening the file.
#[tauri::command]
pub fn reveal_in_finder(
+150 -21
View File
@@ -20,6 +20,7 @@
// than the editor ever does, so the promise that opening a folder writes nothing into it matters
// more here than anywhere: no sidecar, no lock, no mtime bumped, nothing.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering as Memory};
@@ -53,6 +54,14 @@ const LAST_INDEXED_KEY: &str = "last_indexed";
/// enough that a pass is not one transaction per file.
const BATCH: usize = 64;
/// Largest file whose text is read into the full text table. Nothing anybody typed is this big: it
/// is an export, a dataset or a log that happens to end in .txt, and an appended-to log is rewritten
/// on every debounce window for as long as the app is open, so the whole of it would be read and
/// tokenised again every time. The row is still written, because the path is worth finding in quick
/// open and only the text is left out, which is the same answer this file already gives a document
/// that is not UTF-8.
const BODY_MAX: u64 = 8 * 1024 * 1024;
/// How often a pass says where it has got to. The event drives a status line, not a progress bar
/// anybody watches closely, and emitting per file would cost more than the indexing.
const PROGRESS_EVERY: u32 = 64;
@@ -199,8 +208,13 @@ pub fn open(app: &AppHandle) -> Result<(), String> {
fn connect(file: &Path) -> Result<Connection, String> {
let conn = Connection::open(file).map_err(|e| format!("{}: {e}", file.display()))?;
// WAL so a search reads while the indexer writes, and NORMAL because every byte in here is
// derived from a file on disk: the worst a power cut can cost is a rescan.
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
// derived from a file on disk: the worst a power cut can cost is a rescan. The size limit is
// what makes the write ahead log give its space back after a checkpoint rather than keeping the
// high water mark of the largest rebuild for the life of the database, which on a big folder is
// the whole of it left sitting in the app data directory until the file is deleted.
conn.execute_batch(
"PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA journal_size_limit = 33554432;",
)
.map_err(|e| format!("{}: {e}", file.display()))?;
migrate(&conn)?;
Ok(conn)
@@ -367,22 +381,92 @@ fn work(app: AppHandle, jobs: mpsc::Receiver<Job>) {
pass
};
for job in jobs {
let Ok(index) = state(&app) else { continue };
let outcome = match job {
Job::Rebuild(roots) => {
let done = rebuild_pass(&app, &index, &roots, next());
index.rebuilding.store(false, Memory::SeqCst);
done
}
Job::Scan(root) => scan_pass(&app, &index, std::slice::from_ref(&root), next()),
Job::Forget(root_id) => with_conn(&index, |conn| forget_root_rows(conn, &root_id)),
Job::Changed(path) => changed(&app, &index, &path, next()),
Job::Removed(path) => with_conn(&index, |conn| remove_under(conn, &path)),
};
if let Err(e) = outcome {
eprintln!("search index: {e}");
// One blocking wait for the first job, then everything else already sitting behind it, taken as
// a batch rather than one at a time. When the kernel drops filesystem events the watcher reports
// the root itself as modified, and that job is a walk and a sweep of every folder the user has
// open: a two minute build that keeps the kernel dropping queues hundreds of them, and doing
// each one in turn means repeating the same full walk hundreds of times while the index falls
// further behind the disk with every repeat. It has to be collapsed on this side of the channel
// and not by bounding it, because the sender is the debounce callback and holds up the next
// batch of events for as long as it is made to wait.
while let Ok(first) = jobs.recv() {
let mut batch = vec![first];
while let Ok(more) = jobs.try_recv() {
batch.push(more);
}
for job in coalesce(batch) {
let Ok(index) = state(&app) else { continue };
// A panic in here would take this thread with it and nothing above would notice. The
// sender lives in a OnceLock that is never replaced, so every later job would be dropped
// by `send` without a word, and search, quick open and backlinks would go on answering
// from the snapshot the index happened to be holding at that moment for the rest of the
// session. One document the indexer cannot handle is not worth that.
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match job {
Job::Rebuild(roots) => {
let done = rebuild_pass(&app, &index, &roots, next());
index.rebuilding.store(false, Memory::SeqCst);
done
}
Job::Scan(root) => scan_pass(&app, &index, std::slice::from_ref(&root), next()),
Job::Forget(root_id) => with_conn(&index, |conn| forget_root_rows(conn, &root_id)),
Job::Changed(path) => changed(&app, &index, &path, next()),
Job::Removed(path) => with_conn(&index, |conn| remove_under(conn, &path)),
}));
let outcome = match caught {
Ok(outcome) => outcome,
Err(_) => {
// A rebuild that unwound never reached its own `store`, and the flag left set is
// the status line stuck on "indexing" and every later rebuild declining to run.
index.rebuilding.store(false, Memory::SeqCst);
// Clearing the poison is safe because there is no half written state to inherit:
// whatever transaction the panic happened inside was dropped on the way out, and
// dropping a transaction rolls it back, so the database is exactly where it was
// before the job started. Leaving the poison would fail every later lock instead,
// which is the same frozen index arrived at by a different route.
index.conn.clear_poison();
index.status.clear_poison();
let mut status = status_of(&index).unwrap_or_default();
status.phase = "error".to_string();
status.error =
Some("the indexer hit a document it could not handle".to_string());
publish(&app, &index, status);
Err("a job panicked and was abandoned".to_string())
}
};
if let Err(e) = outcome {
eprintln!("search index: {e}");
}
}
}
}
/// One drained batch with the jobs that have been overtaken taken out of it.
///
/// Nothing is reordered, because the order is what makes the queue correct in the first place. A job
/// is dropped only when a later job in the same drain speaks about the same path, and that later one
/// is the one the disk now agrees with, so a create that followed a delete still wins and a delete
/// that followed a create still wins. It is the rule `watch::merge` applies within one debounced
/// batch, applied again across the batches that piled up while the worker was busy. A rebuild, a
/// scan and a forget name no path at all and are always kept.
fn coalesce(batch: Vec<Job>) -> Vec<Job> {
let mut last: HashMap<PathBuf, usize> = HashMap::new();
for (at, job) in batch.iter().enumerate() {
if let Some(path) = job_path(job) {
last.insert(path.to_path_buf(), at);
}
}
batch
.into_iter()
.enumerate()
.filter(|(at, job)| job_path(job).is_none_or(|path| last.get(path) == Some(at)))
.map(|(_, job)| job)
.collect()
}
fn job_path(job: &Job) -> Option<&Path> {
match job {
Job::Changed(path) | Job::Removed(path) => Some(path),
_ => None,
}
}
@@ -397,7 +481,16 @@ fn rebuild_pass(
) -> Result<(), String> {
let ids: Vec<String> = roots.iter().map(|root| root.id.clone()).collect();
with_conn(index, |conn| forget_roots_except(conn, &ids))?;
scan_pass(app, index, roots, pass)
scan_pass(app, index, roots, pass)?;
// FTS5 leaves a segment behind for every rewrite of a row, and a document is rewritten on every
// save, so an index that is never merged is one a long session slowly makes worse at the one
// thing it is for. A full pass is the moment it is fair to do the merging: the user has already
// asked for a walk of every folder they have open, and this costs less than the walk did.
with_conn(index, |conn| {
conn.execute("INSERT INTO docs_fts(docs_fts) VALUES('optimize')", [])
.map(|_| ())
.map_err(|e| e.to_string())
})
}
/// One pass over a set of roots: walk them all first so the total is known before the first file is
@@ -457,6 +550,15 @@ fn changed(app: &AppHandle, index: &Index, path: &Path, pass: i64) -> Result<(),
// Gone again between the event and here, which a debounce window makes perfectly ordinary.
return with_conn(index, |conn| remove_under(conn, path));
};
if is_skipped_below(&root.path, path) {
// The walk hides these folders and so must the watcher, which otherwise reaches the indexer
// with everything the walk refused to look at. An npm install under an open root is a row
// and a body for every README in node_modules, thousands of them, and a sweep will not take
// them back out because they were written by the pass that is sweeping. Checked after the
// stat rather than before it so a deletion under one of these folders is still applied,
// which is what takes away rows an earlier build of this file put there.
return Ok(());
}
if !meta.is_dir() {
if !is_document(path) {
return Ok(());
@@ -571,6 +673,21 @@ fn is_document(path: &Path) -> bool {
matches!(crate::fs::kind_for(path, false), "markdown" | "text")
}
/// Whether a path sits inside one of the folders the tree never shows.
///
/// Only the four unconditional names, and deliberately not the folder's gitignore: these are the
/// ones the tree hides whatever a gitignore says, and building an ignore matcher for every event
/// that arrives would cost more than the indexing it saves. A path that is the root itself strips to
/// an empty relative path with no components at all, so the watcher's "the kernel dropped events,
/// here is the root" report is not caught by this and still rescans everything.
fn is_skipped_below(root: &str, path: &Path) -> bool {
let Ok(rel) = path.strip_prefix(root) else {
return false;
};
rel.components()
.any(|part| crate::fs::ALWAYS_SKIPPED.contains(&part.as_os_str().to_string_lossy().as_ref()))
}
/// One document into the three tables, or one stat if the file has not moved since the last pass.
///
/// The mtime shortcut is what makes a rescan of an unchanged folder cost a walk rather than a read
@@ -602,8 +719,14 @@ fn index_document(
}
// A file that is not UTF-8 is indexed with no text rather than skipped. Its path is still worth
// finding in quick open, and refusing the whole row would make it invisible instead.
let body = fs::read_to_string(path).unwrap_or_default();
// finding in quick open, and refusing the whole row would make it invisible instead. A file
// past `BODY_MAX` is given the same answer for the same reason: nothing an extension can tell
// us says how big a .txt is, and the stat that decided the mtime above already knows.
let body = if meta.len() > BODY_MAX {
String::new()
} else {
fs::read_to_string(path).unwrap_or_default()
};
let title = title_for(path, &body);
let name = path
.file_name()
@@ -1188,7 +1311,13 @@ fn snippet_of(line: &str) -> (String, Vec<MatchRange>) {
if tail.saturating_sub(from) < SNIPPET_MAX {
from = tail.saturating_sub(SNIPPET_MAX).max(lead);
}
let to = tail.min(from + SNIPPET_MAX);
// `from` can end up past `tail` when the line has no text left on it at all. A document is free
// to contain the control character the marks are made of, and every line carrying one is read
// as a line with a match on it here whether there is anything else on it or not, so a line that
// is one stray mark and some spaces reaches this. `tail` is at or after `from` in every ordinary
// case, so this only ever changes that one: what comes out is an empty snippet with no ranges
// rather than a slice that starts after it ends.
let to = tail.max(from).min(from + SNIPPET_MAX);
let mut out = String::new();
let mut shift = from;
+1
View File
@@ -230,6 +230,7 @@ pub fn run() {
fs::root_open,
fs::root_close,
fs::tree_read,
fs::sweep_documents,
fs::reveal_in_finder,
fs::open_external,
fs::file_read,
+68 -6
View File
@@ -9,7 +9,7 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant, SystemTime};
use notify::event::{ModifyKind, RenameMode};
@@ -192,7 +192,16 @@ where
}
let canonical = std::fs::canonicalize(&root).map_err(|e| e.to_string())?;
// Shared rather than owned by the handler, because the root's own disappearance is reported by
// the watchdog below and not by the debouncer, and both have to emit into the same place. Behind
// a lock because a sink is only `Send` and not `Sync`, which also has the two take turns rather
// than interleave two batches in whatever the frontend is doing with them.
let sink = Arc::new(Mutex::new(sink));
let watchdog_sink = Arc::downgrade(&sink);
let watched = canonical.clone();
let watchdog_id = root_id.clone();
let watchdog_path = root.clone();
let handler = move |result: DebounceEventResult| {
let batch = match result {
Ok(batch) => batch,
@@ -205,7 +214,9 @@ where
};
let events = watch_events(&batch, &root_id, &watched, &root);
if !events.is_empty() {
sink(events);
if let Ok(sink) = sink.lock() {
(*sink)(events);
}
}
};
@@ -228,6 +239,43 @@ where
debouncer
.watch(&canonical, RecursiveMode::Recursive)
.map_err(|e| e.to_string())?;
// The root's own removal is the one change this watcher cannot wait for, so it is asked about
// instead. An FSEvents stream is placed on a path and hears nothing that happens above that
// path, and notify does not ask for the flag that would change that, so a parent folder renamed
// or deleted takes the root with it in complete silence. Even the root's own deletion is a
// favour rather than a promise: a folder emptied and removed can come back as one coalesced
// event on the parent, which is not a path this stream matches, and then the whole batch is
// dropped before anything here sees it. Waiting for an event that may never be sent is what left
// a folder deleted out from under the app looking open, with a watcher still in the map holding
// a stream on a path that no longer exists.
//
// One stat per debounce tick settles it on any filesystem, and the answer is terminal: nothing
// further will ever arrive on a stream whose path is gone, so the thread reports the removal and
// stops. `watch_start` hears that removal like any other and drops the watcher.
//
// The thread ends with the watch. The sink is the only thing it holds and it holds it weakly, so
// once the debouncer is dropped and its own thread lets go of the handler there is nothing left
// to report into and nothing to report about.
std::thread::spawn(move || loop {
std::thread::sleep(DEBOUNCE);
let Some(sink) = watchdog_sink.upgrade() else {
return;
};
if !is_gone(&canonical) {
continue;
}
if let Ok(sink) = sink.lock() {
(*sink)(vec![WatchEvent {
root: watchdog_id.clone(),
path: watchdog_path.to_string_lossy().into_owned(),
kind: "removed".to_string(),
old_path: None,
}]);
}
return;
});
Ok(debouncer)
}
@@ -292,10 +340,12 @@ fn watch_events(
merge(&mut events, &mut index, next);
}
// The root itself going away is the one change nothing under it can describe. macOS does report
// it as an event on the watched path, but a folder moved rather than emptied is a single rename
// this side may never see, so the state of the folder is checked rather than waited for.
if !canonical_root.exists() {
// The root itself going away is the one change nothing under it can describe. macOS usually does
// report it as an event on the watched path, but a folder moved rather than emptied is a single
// rename this side may never see, so the state of the folder is checked rather than waited for.
// This is the fast path only: it reports the removal in the same batch as the changes that came
// with it, and the watchdog in `spawn_watcher` is what makes it certain to be reported at all.
if is_gone(canonical_root) {
merge(
&mut events,
&mut index,
@@ -311,6 +361,18 @@ fn watch_events(
events
}
/// Whether the path is not there any more, as against unreadable for some other reason.
///
/// Only a missing file is an answer. A stat that fails because permissions changed or because a
/// volume stopped answering says nothing about whether the folder still exists, and closing the
/// user's open folder on the strength of it would be worse than reporting nothing at all.
fn is_gone(path: &Path) -> bool {
match std::fs::symlink_metadata(path) {
Ok(_) => false,
Err(error) => error.kind() == std::io::ErrorKind::NotFound,
}
}
fn merge(events: &mut Vec<WatchEvent>, index: &mut HashMap<String, usize>, next: WatchEvent) {
match index.get(&next.path) {
// A later `modified` says nothing a create or a rename in the same batch has not already
+10 -11
View File
@@ -6,6 +6,9 @@
// touched is `git status` being empty, plus a byte-level snapshot of every path under the root
// including .git itself.
//
// The repository is built by the suite, under /private/tmp, on the first test that asks for it, and
// there is nothing to set up by hand. `tests/support/notes_repo.rs` is where it comes from.
//
// Run single threaded. The tests share one folder and several of them mutate it.
//
// cargo test --test no_write_on_open -- --test-threads=1 --nocapture
@@ -25,28 +28,24 @@ use margin_docs_lib::fs::{
};
use margin_docs_lib::watch::spawn_watcher;
const REPO: &str = "/private/tmp/margin-notouch/notes-repo";
#[path = "support/notes_repo.rs"]
mod notes_repo;
// ---------------------------------------------------------------- fixture
/// The fixture repository, built on the first call and shared by every test after it.
fn repo() -> PathBuf {
let path = PathBuf::from(REPO);
let path = notes_repo::path().to_path_buf();
assert!(
path.join(".git").is_dir(),
"the fixture repo is missing: {REPO}"
"the fixture repo is missing: {}",
path.display()
);
path
}
fn git(args: &[&str]) -> String {
let out = Command::new("git")
.args(args)
.current_dir(REPO)
.output()
.expect("git runs");
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text
notes_repo::git(args)
}
/// Back to the committed state, then one warm `git status` so the index's stat cache is already
+339
View File
@@ -0,0 +1,339 @@
// The folder the `no_write_on_open` suite runs against, built from nothing every time the test
// binary starts.
//
// It has to be a real git repository and not a `TempDir` full of loose files, because `git status`
// is the oracle the whole suite leans on: an editor that promises to write nothing the user did not
// edit is believable exactly when a checkout of that folder comes back with no lines. A snapshot of
// inodes and timestamps says a byte moved; `git status` says which document it belonged to and
// whether the user would have seen it in their own diff.
//
// It used to be a repository somebody built by hand at a fixed path under /tmp, which meant the
// suite ran on one machine until macOS reaped the folder and then ran nowhere. Everything below
// exists so that the repository is an output of the test run rather than a precondition of it.
//
// The documents are copied out of `src/markdown/corpus/real`, the same real world markdown the
// bridge tests parse, rather than invented here. Reusing them keeps one corpus in the repository
// instead of two, and gives the fixture documents that are the length and shape of the ones people
// actually keep in a notes folder. Nothing in the suite asserts on their bytes, only that they read
// back as UTF-8 and that the tree has the right number of rows, so the corpus is free to grow.
//
// The vendored `node_modules` is generated rather than copied. Several tests need a folder large
// enough that walking it is measurably slower than skipping it, and thirteen thousand tiny files
// that exist to be ignored are not files worth committing.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
/// Every fixture this suite builds is named `margin-notouch-<pid>-<nanos>`, so a later run can
/// recognise the ones earlier runs left behind and work out which of them are finished with.
const PREFIX: &str = "margin-notouch-";
/// 500 packages of 26 entries each is 13,000 paths under `node_modules`. Two tests put a floor
/// under this: one wants more than 5,000 entries there, and one wants a walk of the whole root with
/// the skip turned off to return more than 10,000 rows. Building it costs about a second.
const PACKAGES: usize = 500;
const MODULES_PER_PACKAGE: usize = 21;
/// Where each document in the fixture comes from in `src/markdown/corpus/real`. The paths on the
/// left are named by the tests and cannot move without moving the tests too. There are twelve
/// editable documents here, three more than the suite's floor of ten, and the two sidecars are the
/// files the tests describe as the user's own: a save must not touch either.
const DOCUMENTS: [(&str, &str); 14] = [
("README.md", "margin-readme.md"),
("notes.txt", "margin-claude.md"),
("docs/index.md", "calendar-readme.md"),
("docs/architecture.md", "editor-architecture.md"),
("docs/conventions.md", "editor-conventions.md"),
("docs/design.md", "editor-design.md"),
("docs/guides/setup.md", "editor-setup.md"),
("docs/guides/release.md", "editor-release.md"),
("docs/guides/mobile.md", "calendar-mobile.md"),
("docs/internals/website.md", "margin-website-readme.md"),
("docs/internals/indexing.md", "calendar-architecture.md"),
("docs/internals/watcher.md", "calendar-design.md"),
// Not documents. A `.bak` and a `.tmp` the user owns, committed so that a save deleting one is
// a line of `git status` and not merely an absence somebody has to notice.
("docs/design.md.bak", "editor-design.md"),
("docs/conventions.md.tmp", "editor-conventions.md"),
];
/// Two files that are not text, so the tree has rows the editor cannot open.
const ASSETS: [(&str, &str); 2] = [
("assets/logo.png", "128x128.png"),
("assets/[email protected]", "[email protected]"),
];
/// Built once per test binary, on whichever test calls `pristine()` first, and then shared. The
/// suite runs single threaded, but `LazyLock` is what makes that a property of the fixture rather
/// than a rule someone has to remember.
static REPO: LazyLock<PathBuf> = LazyLock::new(build);
/// The root of the fixture repository. Always canonical, so it starts `/private/tmp/` on macOS and
/// the same folder is also reachable through the `/tmp` symlink, which one test needs.
pub fn path() -> &'static Path {
REPO.as_path()
}
/// Runs git inside the fixture and hands back everything it said, output and errors together. The
/// suite reads these as prose, so a failing command shows up in the assertion that used it rather
/// than as an empty string that quietly looks clean.
pub fn git(args: &[&str]) -> String {
let out = run(path(), args);
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text
}
// ---------------------------------------------------------------- running git
/// Git, with the machine it happens to be running on held at arm's length.
///
/// The fixture is an oracle, so nothing outside it may change what it says. A developer with a
/// `core.excludesFile` full of `*.tmp`, a commit template, a signing key, a `gc.auto` that fires
/// mid run, or a stray `GIT_DIR` in the environment would each turn a green suite red or, worse, a
/// red one green. The config files are pointed at /dev/null and the inherited git variables are
/// dropped, so the only configuration in play is the handful of keys written into the repository
/// itself by `configure` below.
fn run(dir: &Path, args: &[&str]) -> Output {
Command::new("git")
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_OBJECT_DIRECTORY")
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
.env_remove("GIT_CEILING_DIRECTORIES")
.env_remove("GIT_ATTR_NOSYSTEM")
.args(args)
.output()
.unwrap_or_else(|e| {
panic!(
"cannot run `git {}`: {e}\n\
This suite needs the git command line tool on PATH. Its whole method is to ask a \
real repository whether anything moved, so there is no useful way to run it \
without git and it fails here rather than passing on a folder nobody checked.",
args.join(" ")
)
})
}
fn must(dir: &Path, args: &[&str]) {
let out = run(dir, args);
assert!(
out.status.success(),
"building the fixture: `git {}` failed with {}\n{}{}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
// ---------------------------------------------------------------- building
fn build() -> PathBuf {
let tmp = fs::canonicalize("/tmp").unwrap_or_else(|_| std::env::temp_dir());
sweep(&tmp);
// Per process and per instant, so two runs of the suite at once get two repositories and
// neither has to wait for the other. `notes-repo` is a folder inside it rather than the
// temporary folder itself, so the repository has a parent the tests never touch.
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let root = tmp
.join(format!("{PREFIX}{}-{stamp}", std::process::id()))
.join("notes-repo");
fs::create_dir_all(&root)
.unwrap_or_else(|e| panic!("cannot build the fixture at {}: {e}", root.display()));
populate(&root);
commit(&root);
root
}
fn populate(root: &Path) {
let corpus = corpus_dir();
for (rel, source) in DOCUMENTS {
let from = corpus.join(source);
let bytes = fs::read(&from).unwrap_or_else(|e| {
panic!(
"the fixture is built out of the markdown corpus and {} is missing: {e}",
from.display()
)
});
put(&root.join(rel), &bytes);
}
let icons = project_root().join("src-tauri/icons");
for (rel, source) in ASSETS {
let from = icons.join(source);
let bytes = fs::read(&from)
.unwrap_or_else(|e| panic!("the fixture wants {} for an asset: {e}", from.display()));
put(&root.join(rel), &bytes);
}
// The only thing the folder ignores. Deliberately not `*.tmp` or `*.bak`: the tests plant files
// by those names on purpose and need git to report them.
put(
&root.join(".gitignore"),
b"node_modules/\n.DS_Store\n" as &[u8],
);
vendor(root);
}
/// A vendored `node_modules`, big enough that walking it and skipping it are visibly different
/// jobs. The contents are filler; only the count and the shape matter.
fn vendor(root: &Path) {
let node_modules = root.join("node_modules");
for package in 0..PACKAGES {
let dir = node_modules.join(format!("pkg-{package:03}"));
let lib = dir.join("lib");
fs::create_dir_all(&lib)
.unwrap_or_else(|e| panic!("cannot build {}: {e}", lib.display()));
put(
&dir.join("package.json"),
format!("{{\n \"name\": \"pkg-{package:03}\",\n \"version\": \"1.0.{package}\"\n}}\n")
.as_bytes(),
);
put(
&dir.join("index.js"),
b"module.exports = require(\"./lib/mod-00.js\");\n" as &[u8],
);
put(
&dir.join("README.md"),
format!("# pkg-{package:03}\n\nVendored. Not a document, and not the user's writing.\n")
.as_bytes(),
);
for module in 0..MODULES_PER_PACKAGE {
put(
&lib.join(format!("mod-{module:02}.js")),
format!("exports.value = {package} * 100 + {module};\n").as_bytes(),
);
}
}
}
fn commit(root: &Path) {
must(root, &["init", "-q", "-b", "main"]);
configure(root);
must(root, &["add", "-A"]);
must(
root,
&["commit", "-q", "-m", "the folder as the user left it"],
);
let out = run(root, &["status", "--porcelain"]);
let status = String::from_utf8_lossy(&out.stdout);
assert!(
status.is_empty(),
"the fixture did not commit clean, so `git status` cannot be trusted as the oracle:\n{status}"
);
}
/// Written into the repository rather than passed as `-c` flags on every call, so that the settings
/// travel with the fixture and a command run by hand inside it behaves the way the suite's commands
/// do.
fn configure(root: &Path) {
for (key, value) in [
("user.name", "Margin Fixture"),
("user.email", "[email protected]"),
// Signing would ask for a passphrase, and a passphrase in CI is a hang rather than a
// failure.
("commit.gpgsign", "false"),
("tag.gpgsign", "false"),
// A background repack landing between two snapshots would move bytes under .git and read
// as the editor having written something.
("gc.auto", "0"),
("maintenance.auto", "false"),
// The suite asserts on exactly which paths git reports, so the answer must not depend on
// what the person running it happens to ignore everywhere.
("core.excludesFile", "/dev/null"),
("core.autocrlf", "false"),
// Both write to .git while only reading the working tree, which is the one thing every
// snapshot in this suite is watching for.
("core.fsmonitor", "false"),
("core.untrackedCache", "false"),
("core.splitIndex", "false"),
("status.showUntrackedFiles", "normal"),
] {
must(root, &["config", key, value]);
}
}
// ---------------------------------------------------------------- odds and ends
fn put(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.unwrap_or_else(|e| panic!("cannot build {}: {e}", parent.display()));
}
fs::write(path, bytes).unwrap_or_else(|e| panic!("cannot write {}: {e}", path.display()));
}
fn project_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("src-tauri has a parent")
.to_path_buf()
}
fn corpus_dir() -> PathBuf {
project_root().join("src/markdown/corpus/real")
}
/// The fixture outlives the run that built it, on purpose.
///
/// A `TempDir` parked in a `static` is a destructor that never runs, and a suite that quietly
/// relies on that is worse than one that says so. Nothing here tries to delete the repository when
/// the tests finish: the last thing a failing run should do is destroy the evidence, and running
/// `git status` and `git diff` inside the folder is the first thing anyone will want. Thirteen
/// thousand files that each take a block is about fifty megabytes, which is small enough to leave
/// lying about once and much too big to leave lying about once per `cargo test`.
///
/// So the clearing up happens at the start of the next run instead, and it goes by whether the
/// process that built a fixture is still running rather than by how old the folder looks. The pid
/// is in the name for exactly this. Asking that question can only be wrong in the safe direction:
/// a pid that has been recycled reads as alive and the folder is kept, and a folder is only ever
/// removed once nothing is left that could be using it.
fn sweep(tmp: &Path) {
let Ok(entries) = fs::read_dir(tmp) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some(pid) = name.strip_prefix(PREFIX).and_then(|rest| rest.split('-').next()) else {
continue;
};
if !alive(pid) {
fs::remove_dir_all(entry.path()).ok();
}
}
}
/// Signal zero asks whether a process exists without sending it anything. A `kill` that cannot be
/// run at all counts as alive, which leaves the folder where it is rather than deleting a
/// repository on a guess.
fn alive(pid: &str) -> bool {
if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
Command::new("/bin/kill")
.args(["-0", pid])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(true)
}