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 ¬e.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 }