git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

src/write.rs (24.3K)

  1 //! Writing to the vault: atomic file writes, creating notes, ticking tasks,
  2 //! and renaming a file while rewriting every link that points at it.
  3 //!
  4 //! Every edit re-reads the file it changes and re-parses it at that moment
  5 //! rather than trusting the index's copy. The index may be a watcher batch
  6 //! behind — Obsidian or a sync client may have written the file a second
  7 //! ago — and a byte span taken from a stale parse would cut the wrong text.
  8 
  9 use std::io;
 10 use std::ops::Range;
 11 use std::path::Path;
 12 
 13 use crate::canvas;
 14 use crate::index::{parent, stem, FileKind, Index};
 15 use crate::parse::{self, Link, LinkKind};
 16 
 17 #[derive(Debug)]
 18 pub enum WriteError {
 19     NotFound(String),
 20     Exists(String),
 21     InvalidPath(String),
 22     NoTask { path: String, line: usize },
 23     Canvas(String),
 24     Io(io::Error),
 25 }
 26 
 27 impl std::fmt::Display for WriteError {
 28     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 29         match self {
 30             WriteError::NotFound(p) => write!(f, "no such file in the vault: {p}"),
 31             WriteError::Exists(p) => write!(f, "already exists: {p}"),
 32             WriteError::InvalidPath(p) => write!(f, "not a usable vault path: {p}"),
 33             WriteError::NoTask { path, line } => write!(f, "{path}:{}: no task on that line", line + 1),
 34             WriteError::Canvas(e) => write!(f, "{e}"),
 35             WriteError::Io(e) => write!(f, "{e}"),
 36         }
 37     }
 38 }
 39 
 40 impl std::error::Error for WriteError {}
 41 
 42 impl From<io::Error> for WriteError {
 43     fn from(e: io::Error) -> Self {
 44         WriteError::Io(e)
 45     }
 46 }
 47 
 48 /// Write via a hidden temp file in the same folder and a rename, so a
 49 /// reader (Obsidian, a sync client, another cce app) never sees half a
 50 /// file. The temp name starts with `.` so the vault walk and the watcher
 51 /// both ignore it.
 52 pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
 53     let dir = path.parent().ok_or_else(|| io::Error::other("path has no parent"))?;
 54     std::fs::create_dir_all(dir)?;
 55     let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
 56     let tmp = dir.join(format!(".{name}.{}.cce-tmp", std::process::id()));
 57     let result = std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path));
 58     if result.is_err() {
 59         let _ = std::fs::remove_file(&tmp);
 60     }
 61     result
 62 }
 63 
 64 /// One link rewritten by a rename.
 65 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
 66 pub struct LinkEdit {
 67     /// The file holding the link, under its name after the rename.
 68     pub path: String,
 69     /// 0-based line of the link (0 for a canvas file node).
 70     pub line: usize,
 71     pub old: String,
 72     pub new: String,
 73 }
 74 
 75 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
 76 pub struct RenamePlan {
 77     pub from: String,
 78     pub to: String,
 79     pub edits: Vec<LinkEdit>,
 80 }
 81 
 82 /// A vault path a caller may create: relative, no `..`, no hidden part.
 83 fn check_new_path(to: &str) -> Result<String, WriteError> {
 84     let clean = to.trim().trim_start_matches('/');
 85     let bad = clean.is_empty()
 86         || clean.ends_with('/')
 87         || clean.split('/').any(|s| s.is_empty() || s == "." || s == ".." || s.starts_with('.'));
 88     if bad {
 89         return Err(WriteError::InvalidPath(to.to_string()));
 90     }
 91     Ok(clean.to_string())
 92 }
 93 
 94 fn read_text(path: &Path) -> io::Result<String> {
 95     let bytes = std::fs::read(path)?;
 96     String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
 97 }
 98 
 99 /// Apply byte-range replacements, last first so earlier spans stay valid.
100 fn splice(text: &str, mut edits: Vec<(Range<usize>, String)>) -> String {
101     edits.sort_by_key(|(r, _)| std::cmp::Reverse(r.start));
102     let mut out = text.to_string();
103     for (range, with) in edits {
104         out.replace_range(range, &with);
105     }
106     out
107 }
108 
109 /// `../../x/y.md` from folder `from_dir` to vault path `to`.
110 fn relative_path(from_dir: &str, to: &str) -> String {
111     let a: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect();
112     let b: Vec<&str> = to.split('/').collect();
113     let common = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
114     let mut parts: Vec<&str> = vec![".."; a.len() - common];
115     parts.extend(&b[common..]);
116     parts.join("/")
117 }
118 
119 /// Percent-encode what would break a bare markdown destination.
120 fn encode_dest(path: &str) -> String {
121     path.replace('%', "%25").replace(' ', "%20").replace('(', "%28").replace(')', "%29")
122 }
123 
124 impl Index {
125     /// Create a new note (or any file) with this content.
126     pub fn create(&mut self, path: &str, content: &str) -> Result<String, WriteError> {
127         let rel = check_new_path(path)?;
128         if self.lookup_exact(&rel).is_some() {
129             return Err(WriteError::Exists(rel));
130         }
131         atomic_write(&self.abs(&rel), content.as_bytes())?;
132         self.refresh(std::slice::from_ref(&rel));
133         Ok(rel)
134     }
135 
136     /// A file's current text, read from disk (not from the index).
137     pub fn read_text(&self, path: &str) -> io::Result<String> {
138         read_text(&self.abs(path))
139     }
140 
141     /// Replace a note's whole text — an editor's save. Creates the file
142     /// (and its folders) when it does not exist yet.
143     pub fn write_text(&mut self, path: &str, text: &str) -> Result<(), WriteError> {
144         let rel = check_new_path(path)?;
145         atomic_write(&self.abs(&rel), text.as_bytes())?;
146         self.refresh(&[rel]);
147         Ok(())
148     }
149 
150     /// Append a paragraph to a note, creating the note if it is missing.
151     pub fn append(&mut self, path: &str, text: &str) -> Result<(), WriteError> {
152         let rel = check_new_path(path)?;
153         let abs = self.abs(&rel);
154         let mut body = match read_text(&abs) {
155             Ok(t) => t,
156             Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
157             Err(e) => return Err(e.into()),
158         };
159         if !body.is_empty() && !body.ends_with('\n') {
160             body.push('\n');
161         }
162         body.push_str(text);
163         if !text.ends_with('\n') {
164             body.push('\n');
165         }
166         atomic_write(&abs, body.as_bytes())?;
167         self.refresh(&[rel]);
168         Ok(())
169     }
170 
171     /// Set the status character of the task on `line` (0-based) of a note:
172     /// `'x'` to tick it, `' '` to untick.
173     pub fn set_task(&mut self, path: &str, line: usize, status: char) -> Result<(), WriteError> {
174         if FileKind::of(path) != FileKind::Note {
175             return Err(WriteError::NoTask { path: path.to_string(), line });
176         }
177         let abs = self.abs(path);
178         let text = read_text(&abs).map_err(|e| match e.kind() {
179             io::ErrorKind::NotFound => WriteError::NotFound(path.to_string()),
180             _ => e.into(),
181         })?;
182         let note = parse::parse(&text);
183         let task = note
184             .tasks
185             .iter()
186             .find(|t| t.line == line)
187             .ok_or_else(|| WriteError::NoTask { path: path.to_string(), line })?;
188         let at = task.status_at;
189         let old_len = task.status.len_utf8();
190         let new = splice(&text, vec![(at..at + old_len, status.to_string())]);
191         atomic_write(&abs, new.as_bytes())?;
192         self.refresh(&[path.to_string()]);
193         Ok(())
194     }
195 
196     fn lookup_exact(&self, rel: &str) -> Option<&str> {
197         let lower = rel.to_lowercase();
198         self.files().keys().find(|k| k.to_lowercase() == lower).map(String::as_str)
199     }
200 
201     /// What [`rename`](Index::rename) would change, without changing it.
202     pub fn plan_rename(&self, from: &str, to: &str) -> Result<RenamePlan, WriteError> {
203         Ok(self.prepare_rename(from, to)?.plan)
204     }
205 
206     /// Move `from` to `to` and rewrite every link that pointed at it, in
207     /// notes and canvases alike. Links keep their subpath, display text,
208     /// embed `!` and `.md` suffix; the path part is written in the shortest
209     /// form that still reaches the file, or in full when the link was
210     /// written in full. A markdown link keeps being relative or rooted, as
211     /// it was.
212     pub fn rename(&mut self, from: &str, to: &str) -> Result<RenamePlan, WriteError> {
213         let prepared = self.prepare_rename(from, to)?;
214         let before: Vec<Option<String>> =
215             self.outgoing(from).into_iter().map(|(_, t)| t.map(String::from)).collect();
216 
217         let to_abs = self.abs(&prepared.plan.to);
218         if let Some(dir) = to_abs.parent() {
219             std::fs::create_dir_all(dir)?;
220         }
221         std::fs::rename(self.abs(from), &to_abs)?;
222         let mut touched = vec![from.to_string(), prepared.plan.to.clone()];
223         for (path, content) in &prepared.writes {
224             atomic_write(&self.abs(path), content.as_bytes())?;
225             touched.push(path.clone());
226         }
227         self.refresh(&touched);
228 
229         // A move to another folder can change what the moved note's own
230         // short links reach (the same-folder preference). Pin any that now
231         // land elsewhere to the file they reached before.
232         let mut plan = prepared.plan;
233         let to = plan.to.clone();
234         let fixed = self.pin_moved_links(from, &to, &before)?;
235         plan.edits.extend(fixed);
236         Ok(plan)
237     }
238 
239     fn pin_moved_links(
240         &mut self,
241         from: &str,
242         to: &str,
243         before: &[Option<String>],
244     ) -> Result<Vec<LinkEdit>, WriteError> {
245         if parent(from) == parent(to) || FileKind::of(to) != FileKind::Note {
246             return Ok(Vec::new());
247         }
248         let abs = self.abs(to);
249         let text = read_text(&abs)?;
250         let note = parse::parse(&text);
251         let mut edits = Vec::new();
252         let mut out = Vec::new();
253         for (link, was) in note.links.iter().zip(before) {
254             let Some(was) = was else { continue };
255             // A self-link was already rewritten to the new name.
256             let was = if was == from { to } else { was.as_str() };
257             if link.kind != LinkKind::Wiki || self.resolve(Some(to), link).as_deref() == Some(was) {
258                 continue;
259             }
260             let keep_md = link.target.to_lowercase().ends_with(".md");
261             let new = full_form(was, keep_md);
262             out.push(LinkEdit {
263                 path: to.to_string(),
264                 line: link.line,
265                 old: text[link.span.clone()].to_string(),
266                 new: format!(
267                     "{}{}{}",
268                     &text[link.span.start..link.target_span.start],
269                     new,
270                     &text[link.target_span.end..link.span.end]
271                 ),
272             });
273             edits.push((link.target_span.clone(), new));
274         }
275         if !edits.is_empty() {
276             atomic_write(&abs, splice(&text, edits).as_bytes())?;
277             self.refresh(&[to.to_string()]);
278         }
279         Ok(out)
280     }
281 
282     fn prepare_rename(&self, from: &str, to: &str) -> Result<PreparedRename, WriteError> {
283         if self.entry(from).is_none() {
284             return Err(WriteError::NotFound(from.to_string()));
285         }
286         let to = check_new_path(to)?;
287         if let Some(existing) = self.lookup_exact(&to) {
288             // A case-only rename of the same file is fine.
289             if existing != from {
290                 return Err(WriteError::Exists(existing.to_string()));
291             }
292         }
293         let mut plan = RenamePlan { from: from.to_string(), to: to.clone(), edits: Vec::new() };
294         let mut writes = Vec::new();
295 
296         let mut sources: Vec<&str> = self.backlinks(from).iter().map(|b| b.source).collect();
297         // The moved note's own relative markdown links need re-rooting even
298         // when nothing links to it.
299         if FileKind::of(from) == FileKind::Note {
300             sources.push(from);
301         }
302         sources.sort();
303         sources.dedup();
304 
305         for source in sources {
306             let after = if source == from { to.as_str() } else { source };
307             let abs = self.abs(source);
308             let text = read_text(&abs)?;
309             let new_text = match FileKind::of(source) {
310                 FileKind::Canvas => self.rename_in_canvas(&text, source, after, from, &to, &mut plan)?,
311                 _ => self.rename_in_text(&text, source, after, from, &to, None, &mut plan),
312             };
313             if let Some(new_text) = new_text {
314                 writes.push((after.to_string(), new_text));
315             }
316         }
317         Ok(PreparedRename { plan, writes })
318     }
319 
320     /// Rewrite the links in one note's text (or one canvas text node's).
321     /// `source` is where the text lives now, `after` where it will live.
322     #[allow(clippy::too_many_arguments)]
323     fn rename_in_text(
324         &self,
325         text: &str,
326         source: &str,
327         after: &str,
328         from: &str,
329         to: &str,
330         node: Option<&str>,
331         plan: &mut RenamePlan,
332     ) -> Option<String> {
333         let note = parse::parse(text);
334         let moved_dir = parent(source) != parent(after);
335         let mut edits = Vec::new();
336         for link in &note.links {
337             let Some(target) = self.resolve(Some(source), link) else { continue };
338             let new = if target == from {
339                 self.new_link_path(link, text, after, from, to)
340             } else if moved_dir && link.kind == LinkKind::Markdown && is_relative_md(link, source, &target) {
341                 // The note itself moved: re-root its relative links.
342                 Some(markdown_dest(link, text, parent(after), &target))
343             } else {
344                 None
345             };
346             let Some(new) = new else { continue };
347             if new == text[link.target_span.clone()] {
348                 continue;
349             }
350             plan.edits.push(LinkEdit {
351                 path: after.to_string(),
352                 line: if node.is_some() { 0 } else { link.line },
353                 old: text[link.span.clone()].to_string(),
354                 new: format!(
355                     "{}{}{}",
356                     &text[link.span.start..link.target_span.start],
357                     new,
358                     &text[link.target_span.end..link.span.end]
359                 ),
360             });
361             edits.push((link.target_span.clone(), new));
362         }
363         (!edits.is_empty()).then(|| splice(text, edits))
364     }
365 
366     fn rename_in_canvas(
367         &self,
368         text: &str,
369         source: &str,
370         after: &str,
371         from: &str,
372         to: &str,
373         plan: &mut RenamePlan,
374     ) -> Result<Option<String>, WriteError> {
375         let mut board = canvas::from_str(text).map_err(|e| WriteError::Canvas(format!("{source}: {e}")))?;
376         let mut changed = false;
377         for node in board.nodes_mut() {
378             let id = node.str("id").unwrap_or_default();
379             match node.str("type").as_deref() {
380                 Some("file") => {
381                     let Some(file) = node.str("file") else { continue };
382                     if self.resolve_path_exact(&file) == Some(from) {
383                         plan.edits.push(LinkEdit { path: after.to_string(), line: 0, old: file, new: to.to_string() });
384                         node.set_str("file", to);
385                         changed = true;
386                     }
387                 }
388                 Some("text") => {
389                     let body = node.str("text").unwrap_or_default();
390                     if let Some(new) = self.rename_in_text(&body, source, after, from, to, Some(&id), plan) {
391                         node.set_str("text", &new);
392                         changed = true;
393                     }
394                 }
395                 _ => {}
396             }
397         }
398         Ok(changed.then(|| canvas::to_string(&board)))
399     }
400 
401     /// A canvas `file` field is always a full vault path.
402     fn resolve_path_exact(&self, path: &str) -> Option<&str> {
403         self.lookup_exact(path.trim_start_matches('/'))
404     }
405 
406     /// The new path text for a link that pointed at the renamed file.
407     fn new_link_path(&self, link: &Link, text: &str, after: &str, from: &str, to: &str) -> Option<String> {
408         let written = &text[link.target_span.clone()];
409         match link.kind {
410             LinkKind::Wiki => {
411                 let keep_md = written.to_lowercase().ends_with(".md");
412                 if written.contains('/') || !self.short_name_reaches(after, from, to) {
413                     Some(full_form(to, keep_md))
414                 } else {
415                     let name = stem(to);
416                     Some(if keep_md { format!("{name}.md") } else { name.to_string() })
417                 }
418             }
419             LinkKind::Markdown => {
420                 // Rooted if it was written rooted, relative otherwise.
421                 if written.starts_with('/') {
422                     Some(format!("/{}", encode_like(link, text, to)))
423                 } else {
424                     Some(markdown_dest(link, text, parent(after), to))
425                 }
426             }
427             LinkKind::CanvasFile => Some(to.to_string()),
428         }
429     }
430 
431     /// Whether `[[name]]` written in `source` would reach `to` once the
432     /// rename of `from` is done, with no other file of that name winning.
433     fn short_name_reaches(&self, source: &str, from: &str, to: &str) -> bool {
434         let key = stem(to).to_lowercase();
435         let dir = parent(source);
436         let mut rivals: Vec<&str> = self
437             .files()
438             .keys()
439             .map(String::as_str)
440             .filter(|p| *p != from && stem(p).to_lowercase() == key && FileKind::of(p) == FileKind::of(to))
441             .collect();
442         rivals.push(to);
443         rivals.sort_by(|a, b| {
444             (parent(b) == dir).cmp(&(parent(a) == dir)).then(a.len().cmp(&b.len())).then(a.cmp(b))
445         });
446         rivals.first() == Some(&to)
447     }
448 }
449 
450 struct PreparedRename {
451     plan: RenamePlan,
452     /// (path after the rename, new content)
453     writes: Vec<(String, String)>,
454 }
455 
456 /// `folder/Note` for a note (with `.md` only if the link had it), the full
457 /// file name for anything else.
458 fn full_form(path: &str, keep_md: bool) -> String {
459     if FileKind::of(path) == FileKind::Note && !keep_md {
460         path[..path.len() - 3].to_string()
461     } else {
462         path.to_string()
463     }
464 }
465 
466 /// Was this markdown link written relative to its note (rather than from
467 /// the vault root)?
468 fn is_relative_md(link: &Link, source: &str, target: &str) -> bool {
469     let decoded = link.target.trim_start_matches("./");
470     !link.target.starts_with('/')
471         && (link.target.starts_with("../") || format!("{}/{decoded}", parent(source)).trim_start_matches('/') == target)
472 }
473 
474 /// A markdown destination for `target` relative to `dir`, encoded the way
475 /// the original link was.
476 fn markdown_dest(link: &Link, text: &str, dir: &str, target: &str) -> String {
477     encode_like(link, text, &relative_path(dir, target))
478 }
479 
480 /// Angle-bracketed destinations are written raw; bare ones are encoded.
481 fn encode_like(link: &Link, text: &str, path: &str) -> String {
482     let angle = link.target_span.start > 0 && text.as_bytes()[link.target_span.start - 1] == b'<';
483     if angle {
484         path.to_string()
485     } else {
486         encode_dest(path)
487     }
488 }
489 
490 #[cfg(test)]
491 mod tests {
492     use super::*;
493 
494     fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
495         let dir = tempfile::tempdir().unwrap();
496         for (path, text) in files {
497             let p = dir.path().join(path);
498             std::fs::create_dir_all(p.parent().unwrap()).unwrap();
499             std::fs::write(p, text).unwrap();
500         }
501         let index = Index::open(dir.path(), false).unwrap();
502         (dir, index)
503     }
504 
505     fn read(dir: &tempfile::TempDir, path: &str) -> String {
506         std::fs::read_to_string(dir.path().join(path)).unwrap()
507     }
508 
509     #[test]
510     fn rename_rewrites_every_form() {
511         let (dir, mut ix) = vault(&[
512             ("Old.md", "self: [[Old#Top]]\n"),
513             (
514                 "notes/A.md",
515                 "[[Old]] [[Old#Sec|shown]] ![[Old]] [[Old.md]] [[old]] `[[Old]]` [[Other]]\n\
516                  [md](../Old.md) [md2](<../Old.md#Part>) [root](/Old.md)\n",
517             ),
518             ("B.canvas", "{\n\t\"nodes\":[\n\t\t{\"id\":\"f\",\"type\":\"file\",\"file\":\"Old.md\",\"x\":0,\"y\":0,\"width\":1,\"height\":1},\n\t\t{\"id\":\"t\",\"type\":\"text\",\"text\":\"see [[Old]]\",\"x\":0,\"y\":0,\"width\":1,\"height\":1}\n\t],\n\t\"edges\":[]\n}"),
519             ("Other.md", ""),
520         ]);
521         let plan = ix.rename("Old.md", "moved/New Name.md").unwrap();
522         assert_eq!(plan.to, "moved/New Name.md");
523         assert_eq!(
524             read(&dir, "notes/A.md"),
525             "[[New Name]] [[New Name#Sec|shown]] ![[New Name]] [[New Name.md]] [[New Name]] `[[Old]]` [[Other]]\n\
526              [md](../moved/New%20Name.md) [md2](<../moved/New Name.md#Part>) [root](/moved/New%20Name.md)\n"
527         );
528         assert_eq!(read(&dir, "moved/New Name.md"), "self: [[New Name#Top]]\n");
529         let canvas = read(&dir, "B.canvas");
530         assert!(canvas.contains("\"file\":\"moved/New Name.md\""), "{canvas}");
531         assert!(canvas.contains("\"text\":\"see [[New Name]]\""), "{canvas}");
532         assert!(!dir.path().join("Old.md").exists());
533         assert_eq!(ix.backlinks("moved/New Name.md").len(), 11);
534         assert!(ix.unresolved().is_empty(), "{:?}", ix.unresolved().keys().collect::<Vec<_>>());
535     }
536 
537     #[test]
538     fn ambiguous_new_name_is_written_in_full() {
539         let (dir, mut ix) = vault(&[("x/Old.md", ""), ("Taken.md", ""), ("z/L.md", "[[Old]]")]);
540         ix.rename("x/Old.md", "x/Taken.md").unwrap();
541         // `[[Taken]]` from z/ would reach the shorter Taken.md at the root,
542         // so the link names the folder.
543         assert_eq!(read(&dir, "z/L.md"), "[[x/Taken]]");
544         assert_eq!(ix.resolve_text(Some("z/L.md"), "x/Taken").as_deref(), Some("x/Taken.md"));
545     }
546 
547     #[test]
548     fn moved_note_keeps_its_own_links() {
549         let (dir, mut ix) = vault(&[
550             ("a/Mover.md", "[[Dup]] [rel](Sib.md) [[Sib]]"),
551             ("a/Dup.md", ""),
552             ("b/Dup.md", ""),
553             ("a/Sib.md", ""),
554         ]);
555         let plan = ix.rename("a/Mover.md", "b/Mover.md").unwrap();
556         // [[Dup]] reached a/Dup.md before; from b/ it would reach b/Dup.md.
557         assert_eq!(read(&dir, "b/Mover.md"), "[[a/Dup]] [rel](../a/Sib.md) [[Sib]]");
558         assert_eq!(plan.edits.len(), 2);
559         let out: Vec<_> = ix.outgoing("b/Mover.md").into_iter().map(|(_, t)| t).collect();
560         assert_eq!(out, [Some("a/Dup.md"), Some("a/Sib.md"), Some("a/Sib.md")]);
561     }
562 
563     #[test]
564     fn rename_guards() {
565         let (_d, mut ix) = vault(&[("A.md", ""), ("B.md", "")]);
566         assert!(matches!(ix.rename("Nope.md", "C.md"), Err(WriteError::NotFound(_))));
567         assert!(matches!(ix.rename("A.md", "b.md"), Err(WriteError::Exists(_))));
568         assert!(matches!(ix.rename("A.md", "../C.md"), Err(WriteError::InvalidPath(_))));
569         assert!(matches!(ix.rename("A.md", ".hidden/C.md"), Err(WriteError::InvalidPath(_))));
570         // Case-only rename of the same file is allowed.
571         ix.rename("A.md", "a.md").unwrap();
572         assert!(ix.entry("a.md").is_some());
573     }
574 
575     #[test]
576     fn plan_changes_nothing() {
577         let (dir, ix) = vault(&[("A.md", ""), ("L.md", "[[A]]")]);
578         let plan = ix.plan_rename("A.md", "Z.md").unwrap();
579         assert_eq!(plan.edits, [LinkEdit { path: "L.md".into(), line: 0, old: "[[A]]".into(), new: "[[Z]]".into() }]);
580         assert_eq!(read(&dir, "L.md"), "[[A]]");
581         assert!(dir.path().join("A.md").exists());
582     }
583 
584     #[test]
585     fn tasks_create_append() {
586         let (dir, mut ix) = vault(&[("T.md", "- [ ] one\n- [x] two\n")]);
587         ix.set_task("T.md", 0, 'x').unwrap();
588         ix.set_task("T.md", 1, ' ').unwrap();
589         assert_eq!(read(&dir, "T.md"), "- [x] one\n- [ ] two\n");
590         assert!(matches!(ix.set_task("T.md", 5, 'x'), Err(WriteError::NoTask { .. })));
591         let open: Vec<_> = ix.tasks().filter(|(_, t)| t.is_open()).map(|(_, t)| t.text.clone()).collect();
592         assert_eq!(open, ["two"]);
593 
594         ix.create("new/N.md", "hello [[T]]").unwrap();
595         assert_eq!(ix.backlinks("T.md").len(), 1);
596         assert!(matches!(ix.create("new/n.md", ""), Err(WriteError::Exists(_))));
597         ix.append("new/N.md", "more").unwrap();
598         assert_eq!(read(&dir, "new/N.md"), "hello [[T]]\nmore\n");
599         ix.write_text("new/N.md", "replaced").unwrap();
600         assert_eq!(ix.read_text("new/N.md").unwrap(), "replaced");
601         assert!(ix.backlinks("T.md").is_empty());
602         // No temp files left behind.
603         let stray: Vec<_> = std::fs::read_dir(dir.path().join("new")).unwrap().flatten()
604             .filter(|e| e.file_name().to_string_lossy().starts_with('.')).collect();
605         assert!(stray.is_empty());
606     }
607 
608     #[test]
609     fn relative_paths() {
610         assert_eq!(relative_path("a/b", "a/c/x.md"), "../c/x.md");
611         assert_eq!(relative_path("", "x.md"), "x.md");
612         assert_eq!(relative_path("a", "x.md"), "../x.md");
613     }
614 }