things-to-remember checklist
git clone https://git.lucas.co/cce-list.git
src/lib.rs (19.7K)
1 //! The `cce-list` app's model: the item type and the markdown checklists
2 //! on disk.
3 //!
4 //! Lists are plain markdown checklists, one file per list, the file's stem
5 //! its title. With a notes vault configured (`vault { path }` in
6 //! config.kdl, or `$CCE_VAULT`) they are the notes in the vault's `Tasks/`
7 //! folder — the vault is the one source of truth, and whatever syncs the
8 //! vault syncs the lists. Without one they live in
9 //! `~/.local/share/cce-list/lists/`. Which list the app shows is a one-line
10 //! `current` file under `~/.local/share/cce-list/` either way.
11 //!
12 //! Lists used to be mirrored to Google Tasks / iCloud Reminders by a
13 //! `cce-list-sync` helper, which tagged files with `<!-- list:… -->` and
14 //! items with `<!-- uid:… -->` comments. The helper is gone; the comments
15 //! are still parsed so such a file round-trips, and nothing writes new ones.
16 //!
17 //! **Reading is lossless.** A file is any Markdown note: task lines become
18 //! items, keeping how they were written (indentation, `*`/`+`/`1.` bullets,
19 //! a custom status such as Obsidian's `[/]`), and every other line —
20 //! headings, prose, blank lines, fenced code — is kept verbatim with the
21 //! task below it ([`Item::before`]) or after the last one
22 //! ([`ListFile::trailer`]). A load and save of an untouched file writes it
23 //! back byte for byte (bar a missing final newline), so cce-list can open
24 //! an ordinary note without eating it. (It used to adopt every non-task
25 //! line as an item, turning a heading into a checkbox on the next save.)
26
27 use std::path::{Path, PathBuf};
28
29 #[derive(Debug, Clone, PartialEq, Eq, Default)]
30 pub struct Item {
31 pub text: String,
32 pub done: bool,
33 /// A retired sync's `<!-- uid:… -->` tag, kept so the line round-trips.
34 pub uid: Option<String>,
35 /// What came before the `[` as written — ` - `, `* `, `1. ` — when it
36 /// is not the plain `- ` a new item gets.
37 pub prefix: Option<String>,
38 /// The status between the brackets when it is neither ` ` nor `x`
39 /// (`/`, `-`, `>` …). Such an item reads as done, and keeps its mark
40 /// until it is unticked.
41 pub mark: Option<char>,
42 /// Non-task lines just above this item, verbatim, written back before
43 /// it. Deleting the item hands them to the next one ([`remove_item`]).
44 pub before: Vec<String>,
45 }
46
47 /// One checklist: its file stem, a retired sync's list tag (if any), items,
48 /// and whatever non-task lines follow the last item.
49 #[derive(Debug, Clone, PartialEq, Eq, Default)]
50 pub struct ListFile {
51 pub title: String,
52 pub id: Option<String>,
53 pub items: Vec<Item>,
54 pub trailer: Vec<String>,
55 }
56
57 pub fn data_dir() -> PathBuf {
58 std::env::var_os("XDG_DATA_HOME")
59 .map(PathBuf::from)
60 .filter(|p| p.is_absolute())
61 .unwrap_or_else(|| {
62 PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".local/share")
63 })
64 .join("cce-list")
65 }
66
67 /// The vault's `Tasks/` folder when a vault is configured, else the
68 /// app's own `lists/`. Resolved once per process: the vault is not
69 /// expected to move under a running app.
70 pub fn lists_dir() -> PathBuf {
71 static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
72 DIR.get_or_init(|| match cce_vault::config::vault_root(None) {
73 Ok(root) => root.join("Tasks"),
74 Err(_) => data_dir().join("lists"),
75 })
76 .clone()
77 }
78
79 /// Whether lists are vault notes (see [`lists_dir`]).
80 pub fn in_vault() -> bool {
81 lists_dir() != data_dir().join("lists")
82 }
83
84 pub fn current_path() -> PathBuf {
85 data_dir().join("current")
86 }
87
88 /// A title as a file stem. `/` is the one character a stem cannot hold.
89 pub fn safe_title(title: &str) -> String {
90 let t: String = title.trim().replace('/', "-");
91 if t.is_empty() || t == "." || t == ".." { "Untitled".to_string() } else { t }
92 }
93
94 pub fn list_path(title: &str) -> PathBuf {
95 lists_dir().join(format!("{}.md", safe_title(title)))
96 }
97
98 // ── Markdown ──────────────────────────────────────────────────────────────
99
100 /// A task line's parts: (prefix before `[`, status char, text after `] `).
101 /// `- [ ] a`, ` * [x] b`, `1. [/] c`; the bracket must be followed by a
102 /// space or end the line.
103 fn task_line(line: &str) -> Option<(&str, char, &str)> {
104 let body = line.trim_start();
105 let indent = line.len() - body.len();
106 let bullet = if let Some(r) = body.strip_prefix(['-', '*', '+']) {
107 body.len() - r.len()
108 } else {
109 let digits = body.chars().take_while(char::is_ascii_digit).count();
110 let after = &body[digits..];
111 if digits == 0 || !(after.starts_with(". ") || after.starts_with(") ")) {
112 return None;
113 }
114 digits + 1
115 };
116 let rest = body[bullet..].strip_prefix(' ')?;
117 let mut chars = rest.chars();
118 if chars.next()? != '[' {
119 return None;
120 }
121 let status = chars.next()?;
122 if chars.next()? != ']' {
123 return None;
124 }
125 let after = chars.as_str();
126 let text = match after.strip_prefix(' ') {
127 Some(t) => t,
128 None if after.is_empty() => "",
129 None => return None,
130 };
131 let prefix_len = indent + bullet + 1;
132 Some((&line[..prefix_len], status, text))
133 }
134
135 /// Split text into items and the non-task lines after the last one. Lines
136 /// inside fenced code blocks are never tasks.
137 pub fn parse_body(text: &str) -> (Vec<Item>, Vec<String>) {
138 let mut items = Vec::new();
139 let mut pending: Vec<String> = Vec::new();
140 let mut fence: Option<&str> = None;
141 for line in text.lines() {
142 let t = line.trim_start();
143 if let Some(f) = fence {
144 if t.starts_with(f) {
145 fence = None;
146 }
147 pending.push(line.to_string());
148 continue;
149 }
150 if t.starts_with("```") || t.starts_with("~~~") {
151 fence = Some(&t[..3]);
152 pending.push(line.to_string());
153 continue;
154 }
155 match task_line(line) {
156 Some((prefix, status, rest)) => {
157 let (text, uid) = split_uid_comment(rest);
158 let done = status != ' ';
159 items.push(Item {
160 text: text.to_string(),
161 done,
162 uid,
163 prefix: (prefix != "- ").then(|| prefix.to_string()),
164 mark: (done && status != 'x').then_some(status),
165 before: std::mem::take(&mut pending),
166 });
167 }
168 None => pending.push(line.to_string()),
169 }
170 }
171 (items, pending)
172 }
173
174 /// The items of a body, its trailing lines dropped (tests, the migration).
175 pub fn parse_items(text: &str) -> Vec<Item> {
176 parse_body(text).0
177 }
178
179 /// Peel a trailing `<!-- uid:… -->` off an item's text, if present.
180 fn split_uid_comment(rest: &str) -> (&str, Option<String>) {
181 let rest = rest.trim_end();
182 if let Some(open) = rest.rfind("<!-- uid:") {
183 if let Some(inner) = rest[open..].strip_prefix("<!-- uid:").and_then(|s| s.strip_suffix("-->")) {
184 let uid = inner.trim();
185 if !uid.is_empty() {
186 return (rest[..open].trim_end(), Some(uid.to_string()));
187 }
188 }
189 }
190 (rest, None)
191 }
192
193 pub fn serialize_items(items: &[Item]) -> String {
194 let mut out = String::new();
195 for i in items {
196 for line in &i.before {
197 out.push_str(line);
198 out.push('\n');
199 }
200 let mark = if i.done { i.mark.unwrap_or('x') } else { ' ' };
201 let prefix = i.prefix.as_deref().unwrap_or("- ");
202 let sep = if i.text.is_empty() && i.uid.is_none() { "" } else { " " };
203 match &i.uid {
204 Some(uid) => out.push_str(&format!("{prefix}[{mark}]{sep}{} <!-- uid:{uid} -->\n", i.text)),
205 None => out.push_str(&format!("{prefix}[{mark}]{sep}{}\n", i.text)),
206 }
207 }
208 out
209 }
210
211 /// Remove item `i`, handing the lines kept above it to the item that
212 /// follows (or the trailer), so deleting a task never deletes a heading.
213 pub fn remove_item(list: &mut ListFile, i: usize) -> Item {
214 let mut item = list.items.remove(i);
215 let before = std::mem::take(&mut item.before);
216 match list.items.get_mut(i) {
217 Some(next) => {
218 let mut lines = before;
219 lines.append(&mut next.before);
220 next.before = lines;
221 }
222 None => {
223 let mut lines = before;
224 lines.append(&mut list.trailer);
225 list.trailer = lines;
226 }
227 }
228 item
229 }
230
231 /// `Vec::retain` for items, keeping the lines above a dropped item with
232 /// the next kept one. Lines that no kept item follows are returned, for
233 /// the caller to put at the front of the trailer.
234 pub fn retain_items(items: &mut Vec<Item>, mut keep: impl FnMut(&Item) -> bool) -> Vec<String> {
235 let mut carried: Vec<String> = Vec::new();
236 let mut out = Vec::with_capacity(items.len());
237 for mut item in items.drain(..) {
238 if keep(&item) {
239 if !carried.is_empty() {
240 carried.append(&mut item.before);
241 item.before = std::mem::take(&mut carried);
242 }
243 out.push(item);
244 } else {
245 carried.append(&mut item.before);
246 }
247 }
248 *items = out;
249 carried
250 }
251
252 /// A whole list file: the optional `<!-- list:ID -->` header, then the
253 /// body's items and trailing lines.
254 pub fn parse_file(text: &str) -> (Option<String>, Vec<Item>, Vec<String>) {
255 let mut lines = text.lines();
256 let mut first = lines.next();
257 while matches!(first, Some(l) if l.trim().is_empty()) {
258 first = lines.next();
259 }
260 if let Some(id) = first
261 .map(str::trim)
262 .and_then(|l| l.strip_prefix("<!-- list:"))
263 .and_then(|l| l.strip_suffix("-->"))
264 .map(str::trim)
265 .filter(|id| !id.is_empty())
266 {
267 let rest: Vec<&str> = lines.collect();
268 let (items, trailer) = parse_body(&rest.join("\n"));
269 return (Some(id.to_string()), items, trailer);
270 }
271 let (items, trailer) = parse_body(text);
272 (None, items, trailer)
273 }
274
275 /// A whole list file: the optional `<!-- list:ID -->` header, then items.
276 pub fn parse_list(text: &str) -> (Option<String>, Vec<Item>) {
277 let mut lines = text.lines();
278 let mut first = lines.next();
279 while matches!(first, Some(l) if l.trim().is_empty()) {
280 first = lines.next();
281 }
282 if let Some(id) = first
283 .map(str::trim)
284 .and_then(|l| l.strip_prefix("<!-- list:"))
285 .and_then(|l| l.strip_suffix("-->"))
286 .map(str::trim)
287 .filter(|id| !id.is_empty())
288 {
289 let rest: Vec<&str> = lines.collect();
290 return (Some(id.to_string()), parse_items(&rest.join("\n")));
291 }
292 (None, parse_items(text))
293 }
294
295 pub fn serialize_list(id: Option<&str>, items: &[Item]) -> String {
296 let mut out = String::new();
297 if let Some(id) = id {
298 out.push_str(&format!("<!-- list:{id} -->\n"));
299 }
300 out.push_str(&serialize_items(items));
301 out
302 }
303
304 pub fn serialize_file(list: &ListFile) -> String {
305 let mut out = serialize_list(list.id.as_deref(), &list.items);
306 for line in &list.trailer {
307 out.push_str(line);
308 out.push('\n');
309 }
310 out
311 }
312
313 // ── Files ─────────────────────────────────────────────────────────────────
314
315 /// Every list on disk, titles sorted case-insensitively. In the vault, a
316 /// note with no tasks that is more than headings (an index of links, a
317 /// page of prose) is a note that lives in `Tasks/`, not a list, and is
318 /// left out; a fresh list — just its `# Title` — is kept.
319 pub fn load_lists() -> std::io::Result<Vec<ListFile>> {
320 let vault = in_vault();
321 let dir = lists_dir();
322 let mut out = Vec::new();
323 let entries = match std::fs::read_dir(&dir) {
324 Ok(e) => e,
325 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
326 Err(e) => return Err(e),
327 };
328 for entry in entries {
329 let path = entry?.path();
330 if path.extension().and_then(|e| e.to_str()) != Some("md") {
331 continue;
332 }
333 let Some(title) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else {
334 continue;
335 };
336 let text = std::fs::read_to_string(&path)?;
337 let (id, items, trailer) = parse_file(&text);
338 if vault && items.is_empty() && !only_headings(&trailer) {
339 continue;
340 }
341 out.push(ListFile { title, id, items, trailer });
342 }
343 out.sort_by_key(|l| l.title.to_lowercase());
344 Ok(out)
345 }
346
347 fn only_headings(lines: &[String]) -> bool {
348 lines.iter().map(|l| l.trim()).all(|l| l.is_empty() || l.starts_with('#'))
349 }
350
351 /// A new, empty list: in the vault it opens with a `# Title` heading like
352 /// the vault's other task notes.
353 pub fn new_list(title: &str) -> ListFile {
354 let title = safe_title(title);
355 let trailer = if in_vault() { vec![format!("# {title}"), String::new()] } else { Vec::new() };
356 ListFile { title, trailer, ..Default::default() }
357 }
358
359 /// Append an item. Into a list with no items yet, the lines already there
360 /// (its heading) go above it rather than staying below.
361 pub fn push_item(list: &mut ListFile, text: String) {
362 let before = if list.items.is_empty() { std::mem::take(&mut list.trailer) } else { Vec::new() };
363 list.items.push(Item { text, before, ..Default::default() });
364 }
365
366 pub fn load_list(title: &str) -> std::io::Result<ListFile> {
367 let text = std::fs::read_to_string(list_path(title))?;
368 let (id, items, trailer) = parse_file(&text);
369 Ok(ListFile { title: safe_title(title), id, items, trailer })
370 }
371
372 /// Write-temp-then-rename in the same directory, so a crash mid-write never
373 /// leaves a truncated list behind.
374 pub fn save_list(list: &ListFile) -> std::io::Result<()> {
375 atomic_write(&list_path(&list.title), &serialize_file(list))
376 }
377
378 pub fn delete_list(title: &str) -> std::io::Result<()> {
379 match std::fs::remove_file(list_path(title)) {
380 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
381 r => r,
382 }
383 }
384
385 pub fn rename_list(old: &str, new: &str) -> std::io::Result<()> {
386 let (from, to) = (list_path(old), list_path(new));
387 if from == to {
388 return Ok(());
389 }
390 std::fs::rename(from, to)
391 }
392
393 pub fn load_current() -> Option<String> {
394 std::fs::read_to_string(current_path())
395 .ok()
396 .map(|s| s.trim().to_string())
397 .filter(|s| !s.is_empty())
398 }
399
400 pub fn save_current(title: &str) -> std::io::Result<()> {
401 atomic_write(¤t_path(), &format!("{}\n", safe_title(title)))
402 }
403
404 pub fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> {
405 if let Some(dir) = path.parent() {
406 std::fs::create_dir_all(dir)?;
407 }
408 let tmp = path.with_extension("tmp");
409 std::fs::write(&tmp, content)?;
410 std::fs::rename(&tmp, path)
411 }
412
413 #[cfg(test)]
414 mod tests {
415 use super::*;
416
417 #[test]
418 fn checklist_round_trips() {
419 let items = vec![
420 Item { text: "water the plants".into(), done: false, uid: None, ..Default::default() },
421 Item { text: "renew passport".into(), done: true, uid: Some("AB-12".into()), ..Default::default() },
422 ];
423 assert_eq!(parse_items(&serialize_items(&items)), items);
424 }
425
426 /// A hand-edited file survives a load/save cycle untouched: plain lines
427 /// stay lines (they are no longer adopted as items), `[X]` reads as done.
428 #[test]
429 fn foreign_lines_are_kept_not_adopted() {
430 let (items, trailer) = parse_body("buy stamps\n- [X] call mom\n\n - [ ] indented\n");
431 assert_eq!(items.len(), 2);
432 assert_eq!(items[0].before, ["buy stamps"]);
433 assert!(items[0].done && items[0].mark == Some('X'));
434 assert_eq!(items[1].prefix.as_deref(), Some(" - "));
435 assert_eq!(items[1].before, [""]);
436 assert!(trailer.is_empty());
437 }
438
439 #[test]
440 fn a_note_round_trips_byte_for_byte() {
441 let note = "---\ntags: [x]\n---\n# Heading\n\nSome prose.\n- [ ] open\n - [x] done nested\n* [/] in progress\n1. [ ] numbered\n- plain bullet\n- [ ]\n\n```\n- [ ] in code\n```\n## Tail\n";
442 let (id, items, trailer) = parse_file(note);
443 assert_eq!(id, None);
444 assert_eq!(items.len(), 5, "{items:?}");
445 assert_eq!(items[2].mark, Some('/'));
446 let list = ListFile { title: "n".into(), id, items, trailer };
447 assert_eq!(serialize_file(&list), note);
448 }
449
450 #[test]
451 fn deleting_a_task_keeps_the_lines_above_it() {
452 let (id, items, trailer) = parse_file("# A\n- [ ] one\n## B\n- [ ] two\nend\n");
453 let mut list = ListFile { title: "t".into(), id, items, trailer };
454 remove_item(&mut list, 1);
455 assert_eq!(serialize_file(&list), "# A\n- [ ] one\n## B\nend\n");
456 remove_item(&mut list, 0);
457 assert_eq!(serialize_file(&list), "# A\n## B\nend\n");
458 let (_, mut items, _) = parse_file("x\n- [ ] a\ny\n- [ ] b\nz\n- [ ] c\n");
459 let orphans = retain_items(&mut items, |i| i.text == "b");
460 assert_eq!(items[0].before, ["x", "y"]);
461 assert_eq!(orphans, ["z"]);
462 }
463
464 /// Every list on this machine reads and writes back unchanged. Reads
465 /// only; run by hand: `cargo test -p cce-list -- --ignored`.
466 #[test]
467 #[ignore]
468 fn real_lists_round_trip() {
469 let Ok(entries) = std::fs::read_dir(lists_dir()) else { return };
470 for e in entries.flatten() {
471 let text = std::fs::read_to_string(e.path()).unwrap();
472 let (id, items, trailer) = parse_file(&text);
473 let list = ListFile { title: String::new(), id, items, trailer };
474 let back = serialize_file(&list);
475 let want = if text.ends_with('\n') || text.is_empty() { text.clone() } else { format!("{text}\n") };
476 assert_eq!(back, want, "{}", e.path().display());
477 }
478 }
479
480 #[test]
481 fn unticking_drops_a_custom_mark() {
482 let mut items = parse_items("- [/] half\n");
483 items[0].done = false;
484 assert_eq!(serialize_items(&items), "- [ ] half\n");
485 }
486
487 #[test]
488 fn uid_comment_is_identity_not_text() {
489 let parsed = parse_items("- [ ] call mom <!-- uid:X-1 -->\n- [ ] literal <!-- not a uid -->\n");
490 assert_eq!(parsed[0], Item { text: "call mom".into(), done: false, uid: Some("X-1".into()), ..Default::default() });
491 // A comment that is not `uid:` stays part of the text.
492 assert_eq!(parsed[1].uid, None);
493 assert_eq!(parsed[1].text, "literal <!-- not a uid -->");
494 }
495
496 #[test]
497 fn list_header_round_trips_and_is_optional() {
498 let items = vec![Item { text: "a".into(), done: false, uid: None, ..Default::default() }];
499 let text = serialize_list(Some("MDM5"), &items);
500 assert_eq!(parse_list(&text), (Some("MDM5".into()), items.clone()));
501 // No header: a hand-made file is a local-only list, first line and all.
502 assert_eq!(parse_list("- [ ] a\n"), (None, items));
503 // A header that is not `list:` is just a kept line.
504 let (id, kept) = parse_list("<!-- note -->\n- [ ] a\n");
505 assert_eq!(id, None);
506 assert_eq!(kept.len(), 1);
507 assert_eq!(kept[0].before, ["<!-- note -->"]);
508 }
509
510 #[test]
511 fn a_new_list_keeps_its_heading_on_top() {
512 let mut list = ListFile { title: "t".into(), trailer: vec!["# T".into(), String::new()], ..Default::default() };
513 push_item(&mut list, "a".into());
514 push_item(&mut list, "b".into());
515 assert_eq!(serialize_file(&list), "# T\n\n- [ ] a\n- [ ] b\n");
516 }
517
518 #[test]
519 fn index_notes_are_not_lists() {
520 assert!(only_headings(&["# Gifts".into(), String::new()]));
521 assert!(!only_headings(&["# Tasks".into(), "- [[Home]]".into()]));
522 }
523
524 #[test]
525 fn titles_become_safe_stems() {
526 assert_eq!(safe_title("Home/Garden"), "Home-Garden");
527 assert_eq!(safe_title(" "), "Untitled");
528 }
529 }
530