1use std::fs::File;
27
28use crate::vault::{Vault, VaultPath};
29use std::io::Read;
30use std::path::{Path, PathBuf};
31use std::sync::Arc;
32
33use papaya::HashMap;
34use serde::Serialize;
35
36use crate::change_event::ChangeEventType;
37use crate::errors::TaskIndexError;
38use crate::repo::{MarkdownInfo, Repo};
39use crate::tasks::{MarkerRule, Task, TaskKind, TaskStatus, scan_source_tasks_with_markers};
40
41const MAX_SCAN_BYTES: u64 = 4 * 1024 * 1024;
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57pub struct FileTasks {
58 pub url_path: String,
60 pub raw_path: PathBuf,
63 pub title: Option<String>,
69 pub tasks: Vec<Task>,
71 pub open: u32,
73 pub done: u32,
78 #[serde(skip)]
85 folder: String,
86}
87
88impl FileTasks {
89 pub fn new(
91 url_path: impl Into<String>,
92 raw_path: impl Into<PathBuf>,
93 title: Option<String>,
94 tasks: Vec<Task>,
95 ) -> Self {
96 let raw_path = raw_path.into();
97 let folder = folder_of(&raw_path);
98 let open = count_with(&tasks, TaskStatus::Open);
99 let done = count_with(&tasks, TaskStatus::Done);
100 Self {
101 url_path: url_path.into(),
102 raw_path,
103 title,
104 tasks,
105 open,
106 done,
107 folder,
108 }
109 }
110
111 pub fn folder(&self) -> &str {
113 &self.folder
114 }
115
116 pub fn display_title(&self) -> &str {
118 self.title.as_deref().unwrap_or_else(|| {
119 self.raw_path
120 .file_stem()
121 .and_then(|s| s.to_str())
122 .unwrap_or("Untitled")
123 })
124 }
125
126 pub fn tracked(&self) -> u32 {
128 self.open + self.done
129 }
130}
131
132fn count_with(tasks: &[Task], status: TaskStatus) -> u32 {
141 u32::try_from(
142 tasks
143 .iter()
144 .filter(|t| t.kind == TaskKind::Task && t.status == status)
145 .count(),
146 )
147 .unwrap_or(u32::MAX)
148}
149
150fn folder_of(raw_path: &Path) -> String {
154 match raw_path.parent() {
155 Some(parent) if parent.as_os_str().is_empty() => "/".to_string(),
156 Some(parent) => format!("/{}/", crate::url_path::path_to_url(parent)),
159 None => "/".to_string(),
160 }
161}
162
163pub struct TaskIndex {
177 files: HashMap<PathBuf, Arc<FileTasks>>,
180 built: tokio::sync::OnceCell<()>,
187 ignore_globs: Vec<glob::Pattern>,
190 marker_rule: Option<MarkerRule>,
193}
194
195impl Default for TaskIndex {
196 fn default() -> Self {
197 Self::new(&[])
198 }
199}
200
201impl TaskIndex {
202 pub fn new(ignore_globs: &[String]) -> Self {
215 Self::with_markers(ignore_globs, &[])
216 }
217
218 pub fn with_markers(ignore_globs: &[String], markers: &[String]) -> Self {
232 Self {
233 files: HashMap::new(),
234 built: tokio::sync::OnceCell::new(),
235 ignore_globs: compile_ignore_globs(ignore_globs),
236 marker_rule: MarkerRule::new(markers),
237 }
238 }
239
240 fn is_ignored(&self, raw_path: &Path) -> bool {
251 if self.ignore_globs.is_empty() {
252 return false;
253 }
254 let relative = crate::url_path::path_to_url(raw_path);
255 self.ignore_globs
256 .iter()
257 .any(|pattern| pattern.matches(&relative))
258 }
259
260 pub fn is_built(&self) -> bool {
262 self.built.initialized()
263 }
264
265 pub fn len(&self) -> usize {
267 self.files.pin().len()
268 }
269
270 pub fn is_empty(&self) -> bool {
272 self.len() == 0
273 }
274
275 pub fn get(&self, abs_path: &Path) -> Option<Arc<FileTasks>> {
277 self.files.pin().get(abs_path).cloned()
278 }
279
280 pub fn snapshot(&self) -> Vec<Arc<FileTasks>> {
286 self.files.pin().values().cloned().collect()
287 }
288
289 pub async fn ensure_built(self: &Arc<Self>, repo: &Arc<Repo>) -> Result<(), TaskIndexError> {
298 let index = Arc::clone(self);
299 let repo = Arc::clone(repo);
300
301 self.built
302 .get_or_try_init(|| async move {
303 tokio::task::spawn_blocking(move || index.build_blocking(&repo))
304 .await
305 .map_err(|e| TaskIndexError::BuildFailed {
306 reason: e.to_string(),
307 })
308 })
309 .await
310 .map(|_| ())
311 }
312
313 pub fn invalidate_file(&self, abs_path: &Path, event: &ChangeEventType, repo: &Repo) {
331 if !self.is_built() {
332 return;
333 }
334
335 match event {
336 ChangeEventType::Deleted => {
337 self.files.pin().remove(abs_path);
338 }
339 ChangeEventType::Created | ChangeEventType::Modified => {
340 let Some(target) = repo
344 .markdown_files
345 .pin()
346 .get(abs_path)
347 .map(|info| ScanTarget::from_info(abs_path, info))
348 else {
349 self.files.pin().remove(abs_path);
350 return;
351 };
352
353 if self.is_ignored(&target.raw_path) {
358 self.files.pin().remove(abs_path);
359 return;
360 }
361
362 let mut buffer = String::new();
363 match target.scan(
365 repo.vault().as_ref(),
366 &mut buffer,
367 self.marker_rule.as_ref(),
368 ) {
369 Some(file_tasks) => {
372 self.files
373 .pin()
374 .insert(abs_path.to_path_buf(), Arc::new(file_tasks));
375 }
376 None => {
377 self.files.pin().remove(abs_path);
378 }
379 }
380 }
381 }
382 }
383
384 pub fn rebuild_if_built(&self, repo: &Repo) {
395 if !self.is_built() {
396 return;
397 }
398
399 let fresh = self.scan_all(repo);
400 let files = self.files.pin();
401 let stale: Vec<PathBuf> = {
404 let fresh_paths: std::collections::HashSet<&PathBuf> =
405 fresh.iter().map(|(path, _)| path).collect();
406 files
407 .keys()
408 .filter(|key| !fresh_paths.contains(*key))
409 .cloned()
410 .collect()
411 };
412
413 for (path, file_tasks) in fresh {
414 files.insert(path, file_tasks);
415 }
416 for path in stale {
417 files.remove(&path);
418 }
419 }
420
421 fn build_blocking(&self, repo: &Repo) {
423 let files = self.files.pin();
424 for (path, file_tasks) in self.scan_all(repo) {
425 files.insert(path, file_tasks);
426 }
427 }
428
429 fn scan_all(&self, repo: &Repo) -> Vec<(PathBuf, Arc<FileTasks>)> {
432 let start = std::time::Instant::now();
433
434 let targets: Vec<ScanTarget> = repo
439 .markdown_files
440 .pin()
441 .iter()
442 .filter(|(_, info)| !self.is_ignored(&info.raw_path))
445 .map(|(abs_path, info)| ScanTarget::from_info(abs_path, info))
446 .collect();
447
448 let total = targets.len();
449 let vault = repo.vault().as_ref();
450 let mut buffer = String::new();
452 let mut found = Vec::new();
453
454 for target in targets {
455 let scanned = target.scan(vault, &mut buffer, self.marker_rule.as_ref());
458 if let Some(file_tasks) = scanned {
459 found.push((target.abs_path, Arc::new(file_tasks)));
460 }
461 }
462
463 tracing::debug!(
464 "Task scan: {}/{total} files contain tasks ({:?})",
465 found.len(),
466 start.elapsed()
467 );
468 found
469 }
470}
471
472struct ScanTarget {
474 abs_path: PathBuf,
475 raw_path: PathBuf,
476 url_path: String,
477 title: Option<String>,
478}
479
480impl ScanTarget {
481 fn from_info(abs_path: &Path, info: &MarkdownInfo) -> Self {
482 Self {
483 abs_path: abs_path.to_path_buf(),
484 raw_path: info.raw_path.clone(),
485 url_path: info.url_path.clone(),
486 title: info
487 .frontmatter
488 .as_ref()
489 .and_then(|fm| fm.get("title"))
490 .and_then(|v| v.as_str())
491 .map(str::to_string),
492 }
493 }
494
495 fn scan(
501 &self,
502 vault: &dyn Vault,
503 buffer: &mut String,
504 markers: Option<&MarkerRule>,
505 ) -> Option<FileTasks> {
506 let path = VaultPath::from_relative_native(&self.raw_path).ok()?;
511
512 if let Err(e) = read_capped_from(vault, &path, buffer) {
513 tracing::debug!("Task scan skipped {path}: {e}");
514 return None;
515 }
516
517 let tasks = scan_source_tasks_with_markers(buffer, markers);
521 if tasks.is_empty() {
522 return None;
523 }
524 Some(FileTasks::new(
525 self.url_path.clone(),
526 self.raw_path.clone(),
527 self.title.clone(),
528 tasks,
529 ))
530 }
531}
532
533fn compile_ignore_globs(patterns: &[String]) -> Vec<glob::Pattern> {
539 patterns
540 .iter()
541 .filter_map(|pattern| {
542 glob::Pattern::new(pattern)
543 .map_err(|e| tracing::warn!("Invalid tasks_ignore_globs pattern '{pattern}': {e}"))
544 .ok()
545 })
546 .collect()
547}
548
549fn read_capped_from(
555 vault: &dyn Vault,
556 path: &VaultPath,
557 buffer: &mut String,
558) -> std::io::Result<()> {
559 if let Some(local) = vault.local_path(path) {
560 return read_capped(&local, buffer);
561 }
562 let entry = vault
563 .stat(path)?
564 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::NotFound))?;
565 if entry.size > MAX_SCAN_BYTES {
566 return Err(oversized(entry.size));
567 }
568 let text = String::from_utf8(vault.read(path)?)
569 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
570 buffer.clear();
571 buffer.push_str(&text);
572 Ok(())
573}
574
575fn oversized(len: u64) -> std::io::Error {
577 std::io::Error::other(format!(
578 "file is {len} bytes, over the {MAX_SCAN_BYTES}-byte task-scan limit"
579 ))
580}
581
582fn read_capped(path: &Path, buffer: &mut String) -> std::io::Result<()> {
588 let mut file = File::open(path)?;
589 let len = file.metadata()?.len();
590 if len > MAX_SCAN_BYTES {
591 return Err(oversized(len));
592 }
593
594 buffer.clear();
595 buffer.reserve(usize::try_from(len).unwrap_or(0));
598 file.read_to_string(buffer)?;
599 Ok(())
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::tasks::parse_task_line;
606
607 struct TestRepo {
610 repo: Arc<Repo>,
611 root: PathBuf,
617 _dir: tempfile::TempDir,
618 }
619
620 impl TestRepo {
621 fn path(&self, relative: &str) -> PathBuf {
622 self.root.join(relative)
623 }
624
625 fn write(&self, relative: &str, contents: &str) -> PathBuf {
626 let path = self.path(relative);
627 if let Some(parent) = path.parent() {
628 std::fs::create_dir_all(parent).expect("create parent dir");
629 }
630 std::fs::write(&path, contents).expect("write fixture file");
631 path
632 }
633 }
634
635 fn repo_over(files: &[(&str, &str)]) -> TestRepo {
636 let dir = tempfile::tempdir().expect("create temp dir");
637 let root = dir.path().canonicalize().expect("canonicalize temp dir");
638 for (rel, contents) in files {
639 let path = root.join(rel);
640 if let Some(parent) = path.parent() {
641 std::fs::create_dir_all(parent).expect("create parent dir");
642 }
643 std::fs::write(&path, contents).expect("write fixture file");
644 }
645
646 let repo = Repo::init(
647 root.clone(),
648 "static",
649 &["md".to_string()],
650 &[],
651 &[],
652 "index.md",
653 &[],
654 &[],
655 );
656 repo.scan_all().expect("scan repo");
657 TestRepo {
658 repo: Arc::new(repo),
659 root,
660 _dir: dir,
661 }
662 }
663
664 fn task(line: &str) -> Task {
665 parse_task_line(line, 1).expect("fixture line is a task")
666 }
667
668 fn file_tasks(url: &str, raw: &str, lines: &[&str]) -> FileTasks {
669 FileTasks::new(
670 url,
671 PathBuf::from(raw),
672 None,
673 lines.iter().copied().map(task).collect(),
674 )
675 }
676
677 #[test]
680 fn counts_exclude_canceled_from_both() {
681 let file = file_tasks(
682 "/notes/",
683 "notes.md",
684 &[
685 "- [ ] open one",
686 "- [ ] open two",
687 "- [x] done one",
688 "- [-] canceled",
689 "- [>] moved > 2026-08-04",
690 ],
691 );
692 assert_eq!(file.open, 2);
693 assert_eq!(file.done, 1);
694 assert_eq!(file.tracked(), 3);
697 assert_eq!(file.tasks.len(), 5);
698 }
699
700 #[test]
701 fn folder_comes_from_the_source_path_not_the_url() {
702 let note = file_tasks("/docs/notes/weekly/", "docs/notes/weekly.md", &["- [ ] a"]);
704 assert_eq!(note.folder(), "/docs/notes/");
705
706 let index = file_tasks("/docs/", "docs/index.md", &["- [ ] a"]);
709 assert_eq!(index.folder(), "/docs/");
710
711 let root = file_tasks("/readme/", "readme.md", &["- [ ] a"]);
713 assert_eq!(root.folder(), "/");
714 }
715
716 #[test]
717 fn display_title_falls_back_to_the_file_stem() {
718 let untitled = file_tasks("/docs/guide/", "docs/guide.md", &["- [ ] a"]);
719 assert_eq!(untitled.display_title(), "guide");
720
721 let titled = FileTasks::new(
722 "/docs/guide/",
723 PathBuf::from("docs/guide.md"),
724 Some("The Guide".to_string()),
725 vec![task("- [ ] a")],
726 );
727 assert_eq!(titled.display_title(), "The Guide");
728 }
729
730 fn mem_repo(vault: crate::vault::MemVault) -> (Arc<Repo>, Arc<crate::vault::MemVault>) {
734 let vault = Arc::new(vault);
735 let repo = Repo::init_with_vault(
736 vault.clone(),
737 "static",
738 &["md".to_string()],
739 &[],
740 &[],
741 "index.md",
742 &[],
743 &[],
744 );
745 repo.scan_all().expect("scan repo");
746 (Arc::new(repo), vault)
747 }
748
749 #[tokio::test]
752 async fn builds_and_invalidates_through_a_vault_without_local_paths() {
753 let (repo, vault) = mem_repo(
754 crate::vault::MemVault::new()
755 .with_file(
756 "todo.md",
757 "# Todo\n\n- [ ] write the vault\n- [x] read the plan\n",
758 )
759 .with_file("prose.md", "# Prose\n\nNo tasks here.\n"),
760 );
761 let index = Arc::new(TaskIndex::new(&[]));
762 index.ensure_built(&repo).await.expect("build");
763
764 let key = repo.vault().key(&VaultPath::new("todo.md").unwrap());
765 let tasks = index.get(&key).expect("todo.md indexed");
766 assert_eq!(tasks.tasks.len(), 2);
767 assert_eq!(index.len(), 1, "prose.md has no tasks");
768
769 vault.insert_file("todo.md", "# Todo\n\n- [x] write the vault\n");
770 repo.invalidate_file(&key, &ChangeEventType::Modified);
771 index.invalidate_file(&key, &ChangeEventType::Modified, &repo);
772 assert_eq!(index.get(&key).expect("still indexed").tasks.len(), 1);
773
774 let huge = "- [ ] x\n".repeat(usize::try_from(MAX_SCAN_BYTES).unwrap_or(0) / 8 + 1);
776 vault.insert_file("huge.md", &huge);
777 let huge_key = repo.vault().key(&VaultPath::new("huge.md").unwrap());
778 repo.invalidate_file(&huge_key, &ChangeEventType::Created);
779 index.invalidate_file(&huge_key, &ChangeEventType::Created, &repo);
780 assert!(index.get(&huge_key).is_none());
781 }
782
783 #[tokio::test]
786 async fn skips_files_that_are_not_downloaded() {
787 let vault = crate::vault::MemVault::new()
788 .with_file("local.md", "- [ ] here\n")
789 .with_file("cloud.md", "- [ ] there\n");
790 vault.set_availability("cloud.md", crate::vault::Availability::NotDownloaded);
791 let (repo, _vault) = mem_repo(vault);
792 let index = Arc::new(TaskIndex::new(&[]));
793 index.ensure_built(&repo).await.expect("build");
794 assert_eq!(index.len(), 1);
795 }
796
797 #[test]
800 fn read_capped_reuses_the_buffer_without_appending() {
801 let dir = tempfile::tempdir().expect("temp dir");
802 let first = dir.path().join("a.md");
803 let second = dir.path().join("b.md");
804 std::fs::write(&first, "aaaa").expect("write");
805 std::fs::write(&second, "bb").expect("write");
806
807 let mut buffer = String::new();
808 read_capped(&first, &mut buffer).expect("read a");
809 assert_eq!(buffer, "aaaa");
810 read_capped(&second, &mut buffer).expect("read b");
811 assert_eq!(buffer, "bb", "buffer must be cleared between files");
812 }
813
814 #[test]
815 fn read_capped_refuses_oversized_files() {
816 let dir = tempfile::tempdir().expect("temp dir");
817 let path = dir.path().join("huge.md");
818 let oversized = vec![b'x'; usize::try_from(MAX_SCAN_BYTES).unwrap_or(0) + 1];
819 std::fs::write(&path, &oversized).expect("write");
820
821 let mut buffer = String::new();
822 assert!(read_capped(&path, &mut buffer).is_err());
823 }
824
825 #[test]
826 fn read_capped_rejects_invalid_utf8() {
827 let dir = tempfile::tempdir().expect("temp dir");
828 let path = dir.path().join("binary.md");
829 std::fs::write(&path, [0xff, 0xfe, 0x00]).expect("write");
830
831 let mut buffer = String::new();
832 assert!(read_capped(&path, &mut buffer).is_err());
833 }
834
835 #[tokio::test]
838 async fn index_is_not_built_until_first_use() {
839 let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
840 let index = Arc::new(TaskIndex::new(&[]));
841
842 assert!(!index.is_built());
844 assert!(index.is_empty());
845
846 index.invalidate_file(
848 &fixture.path("notes.md"),
849 &ChangeEventType::Modified,
850 &fixture.repo,
851 );
852 assert!(!index.is_built());
853 assert!(index.is_empty());
854
855 index.rebuild_if_built(&fixture.repo);
857 assert!(!index.is_built());
858 assert!(index.is_empty());
859
860 index
861 .ensure_built(&fixture.repo)
862 .await
863 .expect("build succeeds");
864 assert!(index.is_built());
865 assert_eq!(index.len(), 1);
866 }
867
868 #[tokio::test]
869 async fn build_stores_only_files_that_contain_tasks() {
870 let fixture = repo_over(&[
871 ("with.md", "# Notes\n\n- [ ] a task\n"),
872 ("without.md", "# Notes\n\nJust prose.\n"),
873 ("fenced.md", "```\n- [ ] not a task\n```\n"),
874 ]);
875 let index = Arc::new(TaskIndex::new(&[]));
876 index
877 .ensure_built(&fixture.repo)
878 .await
879 .expect("build succeeds");
880
881 assert_eq!(index.len(), 1);
882 let stored = index.get(&fixture.path("with.md")).expect("indexed");
883 assert_eq!(stored.tasks.len(), 1);
884 assert_eq!(stored.tasks[0].text, "a task");
885 assert_eq!(stored.url_path, "/with/");
886 assert_eq!(stored.raw_path, PathBuf::from("with.md"));
887 }
888
889 #[tokio::test]
890 async fn build_records_frontmatter_titles_and_counts() {
891 let fixture = repo_over(&[(
892 "docs/plan.md",
893 "---\ntitle: The Plan\n---\n\n- [ ] one\n- [x] two\n- [-] three\n",
894 )]);
895 let index = Arc::new(TaskIndex::new(&[]));
896 index
897 .ensure_built(&fixture.repo)
898 .await
899 .expect("build succeeds");
900
901 let stored = index.get(&fixture.path("docs/plan.md")).expect("indexed");
902 assert_eq!(stored.title.as_deref(), Some("The Plan"));
903 assert_eq!(stored.display_title(), "The Plan");
904 assert_eq!((stored.open, stored.done), (1, 1));
905 assert_eq!(stored.folder(), "/docs/");
906 assert_eq!(stored.tasks[0].line, 5);
908 }
909
910 #[tokio::test]
911 async fn ensure_built_is_idempotent_and_single_flight() {
912 let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
913 let index = Arc::new(TaskIndex::new(&[]));
914
915 let calls = (0..8).map(|_| {
917 let index = Arc::clone(&index);
918 let repo = Arc::clone(&fixture.repo);
919 async move { index.ensure_built(&repo).await }
920 });
921 for result in futures::future::join_all(calls).await {
922 result.expect("build succeeds");
923 }
924
925 assert_eq!(index.len(), 1);
926 let stored = index.get(&fixture.path("notes.md")).expect("indexed");
927 assert_eq!(
928 stored.tasks.len(),
929 1,
930 "a re-run would have duplicated tasks"
931 );
932 }
933
934 #[tokio::test]
937 async fn invalidate_rescans_a_modified_file() {
938 let fixture = repo_over(&[("notes.md", "- [ ] before\n")]);
939 let index = Arc::new(TaskIndex::new(&[]));
940 index
941 .ensure_built(&fixture.repo)
942 .await
943 .expect("build succeeds");
944
945 let path = fixture.write("notes.md", "- [x] after\n- [ ] and more\n");
946 index.invalidate_file(&path, &ChangeEventType::Modified, &fixture.repo);
947
948 let stored = index.get(&path).expect("still indexed");
949 assert_eq!(stored.tasks.len(), 2);
950 assert_eq!(stored.tasks[0].text, "after");
951 assert_eq!((stored.open, stored.done), (1, 1));
952 }
953
954 #[tokio::test]
955 async fn invalidate_drops_a_file_that_lost_its_last_task() {
956 let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
957 let index = Arc::new(TaskIndex::new(&[]));
958 index
959 .ensure_built(&fixture.repo)
960 .await
961 .expect("build succeeds");
962 assert_eq!(index.len(), 1);
963
964 let path = fixture.write("notes.md", "just prose now\n");
965 index.invalidate_file(&path, &ChangeEventType::Modified, &fixture.repo);
966
967 assert!(
968 index.get(&path).is_none(),
969 "empty file must leave the index"
970 );
971 assert!(index.is_empty());
972 }
973
974 #[tokio::test]
975 async fn invalidate_removes_a_deleted_file() {
976 let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
977 let index = Arc::new(TaskIndex::new(&[]));
978 index
979 .ensure_built(&fixture.repo)
980 .await
981 .expect("build succeeds");
982
983 let path = fixture.path("notes.md");
984 std::fs::remove_file(&path).expect("delete");
985 fixture
986 .repo
987 .invalidate_file(&path, &ChangeEventType::Deleted);
988 index.invalidate_file(&path, &ChangeEventType::Deleted, &fixture.repo);
989
990 assert!(index.is_empty());
991 }
992
993 #[tokio::test]
994 async fn invalidate_indexes_a_newly_created_file() {
995 let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
996 let index = Arc::new(TaskIndex::new(&[]));
997 index
998 .ensure_built(&fixture.repo)
999 .await
1000 .expect("build succeeds");
1001
1002 let path = fixture.write("fresh.md", "---\ntitle: Fresh\n---\n\n- [ ] brand new\n");
1003 fixture
1005 .repo
1006 .invalidate_file(&path, &ChangeEventType::Created);
1007 index.invalidate_file(&path, &ChangeEventType::Created, &fixture.repo);
1008
1009 let stored = index.get(&path).expect("indexed");
1010 assert_eq!(stored.tasks[0].text, "brand new");
1011 assert_eq!(stored.title.as_deref(), Some("Fresh"));
1012 }
1013
1014 #[tokio::test]
1015 async fn invalidate_ignores_files_the_repo_does_not_track() {
1016 let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
1017 let index = Arc::new(TaskIndex::new(&[]));
1018 index
1019 .ensure_built(&fixture.repo)
1020 .await
1021 .expect("build succeeds");
1022
1023 let asset = fixture.write("photo.png", "not markdown");
1026 index.invalidate_file(&asset, &ChangeEventType::Created, &fixture.repo);
1027
1028 assert_eq!(index.len(), 1);
1029 assert!(index.get(&asset).is_none());
1030 }
1031
1032 #[tokio::test]
1035 async fn rebuild_refreshes_adds_and_prunes_in_one_pass() {
1036 let fixture = repo_over(&[
1037 ("keep.md", "- [ ] unchanged\n"),
1038 ("change.md", "- [ ] before\n"),
1039 ("gone.md", "- [ ] doomed\n"),
1040 ]);
1041 let index = Arc::new(TaskIndex::new(&[]));
1042 index
1043 .ensure_built(&fixture.repo)
1044 .await
1045 .expect("build succeeds");
1046 assert_eq!(index.len(), 3);
1047
1048 fixture.write("change.md", "- [x] after\n");
1051 std::fs::remove_file(fixture.path("gone.md")).expect("delete");
1052 fixture.write("added.md", "- [ ] newcomer\n");
1053 fixture.repo.full_rescan();
1054 index.rebuild_if_built(&fixture.repo);
1055
1056 assert_eq!(index.len(), 3);
1057 assert!(index.get(&fixture.path("gone.md")).is_none(), "pruned");
1058 assert_eq!(
1059 index
1060 .get(&fixture.path("change.md"))
1061 .expect("indexed")
1062 .tasks[0]
1063 .text,
1064 "after",
1065 "refreshed"
1066 );
1067 assert_eq!(
1068 index.get(&fixture.path("added.md")).expect("indexed").tasks[0].text,
1069 "newcomer",
1070 "added"
1071 );
1072 assert!(index.get(&fixture.path("keep.md")).is_some(), "kept");
1073 }
1074
1075 fn templates_repo() -> TestRepo {
1080 repo_over(&[
1081 ("templates/checklist.md", "- [ ] template step\n"),
1082 ("templates/nested/deep.md", "- [ ] nested template step\n"),
1083 ("docs/templates/local.md", "- [ ] docs template step\n"),
1084 ("docs/plan.md", "- [ ] real work\n"),
1085 ])
1086 }
1087
1088 fn indexed_paths(index: &TaskIndex) -> Vec<String> {
1090 let mut paths: Vec<String> = index
1091 .snapshot()
1092 .iter()
1093 .map(|f| crate::url_path::path_to_url(&f.raw_path))
1094 .collect();
1095 paths.sort();
1096 paths
1097 }
1098
1099 async fn built_index(fixture: &TestRepo, globs: &[&str]) -> Arc<TaskIndex> {
1100 let globs: Vec<String> = globs.iter().map(|g| (*g).to_string()).collect();
1101 let index = Arc::new(TaskIndex::new(&globs));
1102 index
1103 .ensure_built(&fixture.repo)
1104 .await
1105 .expect("build succeeds");
1106 index
1107 }
1108
1109 #[test]
1112 fn documented_pattern_semantics() {
1113 let matches = |pattern: &str, path: &str| {
1114 TaskIndex::new(&[pattern.to_string()]).is_ignored(Path::new(path))
1115 };
1116
1117 assert!(matches("templates/**", "templates/a.md"));
1119 assert!(matches("templates/**", "templates/deep/b.md"));
1120 assert!(!matches("templates/**", "docs/templates/a.md"));
1121
1122 assert!(matches("**/templates/**", "templates/a.md"));
1125 assert!(matches("**/templates/**", "docs/templates/a.md"));
1126 assert!(matches("**/templates/**", "a/b/templates/c.md"));
1127 assert!(!matches("**/templates/**", "templates.md"));
1128
1129 assert!(matches("docs/templates/**", "docs/templates/a.md"));
1131 assert!(!matches("docs/templates/**", "templates/a.md"));
1132
1133 assert!(matches(
1135 "**/*.checklist.md",
1136 "templates/onboarding.checklist.md"
1137 ));
1138 assert!(!matches("**/*.checklist.md", "templates/onboarding.md"));
1139
1140 assert!(!matches("templates", "templates/a.md"));
1142
1143 assert!(matches("templates/*.md", "templates/deep/a.md"));
1146 }
1147
1148 #[tokio::test]
1152 async fn ignore_globs_exclude_a_folder_and_everything_under_it() {
1153 let fixture = templates_repo();
1154 let index = built_index(&fixture, &["templates/**"]).await;
1155
1156 assert_eq!(
1157 indexed_paths(&index),
1158 vec!["docs/plan.md", "docs/templates/local.md"]
1159 );
1160 assert!(
1161 index
1162 .get(&fixture.path("templates/nested/deep.md"))
1163 .is_none(),
1164 "`**` must reach past the folder's direct children"
1165 );
1166 assert!(
1167 index.get(&fixture.path("docs/plan.md")).is_some(),
1168 "files outside the pattern must be untouched"
1169 );
1170 }
1171
1172 #[tokio::test]
1176 async fn leading_double_star_matches_at_the_root_too() {
1177 let fixture = templates_repo();
1178 let index = built_index(&fixture, &["**/templates/**"]).await;
1179
1180 assert_eq!(indexed_paths(&index), vec!["docs/plan.md"]);
1181 }
1182
1183 #[tokio::test]
1186 async fn a_bare_folder_name_matches_nothing() {
1187 let fixture = templates_repo();
1188 let index = built_index(&fixture, &["templates"]).await;
1189
1190 assert_eq!(index.len(), 4, "a bare folder name excludes nothing");
1191 }
1192
1193 #[tokio::test]
1195 async fn no_ignore_globs_excludes_nothing() {
1196 let fixture = templates_repo();
1197 let index = built_index(&fixture, &[]).await;
1198
1199 assert_eq!(index.len(), 4);
1200 }
1201
1202 #[tokio::test]
1206 async fn a_malformed_pattern_is_dropped_not_applied() {
1207 let fixture = templates_repo();
1208 let index = built_index(&fixture, &["templates/**bad", "templates/**"]).await;
1209
1210 assert_eq!(
1211 indexed_paths(&index),
1212 vec!["docs/plan.md", "docs/templates/local.md"],
1213 "the valid pattern still applies"
1214 );
1215 }
1216
1217 #[tokio::test]
1221 async fn invalidate_does_not_readd_an_ignored_file() {
1222 let fixture = templates_repo();
1223 let index = built_index(&fixture, &["templates/**"]).await;
1224 assert_eq!(index.len(), 2);
1225
1226 let path = fixture.write("templates/checklist.md", "- [ ] edited template step\n");
1227 fixture
1229 .repo
1230 .invalidate_file(&path, &ChangeEventType::Modified);
1231 index.invalidate_file(&path, &ChangeEventType::Modified, &fixture.repo);
1232
1233 assert!(
1234 index.get(&path).is_none(),
1235 "editing an ignored file must not index it"
1236 );
1237 assert_eq!(index.len(), 2);
1238 }
1239
1240 #[tokio::test]
1243 async fn invalidate_does_not_add_a_newly_created_ignored_file() {
1244 let fixture = templates_repo();
1245 let index = built_index(&fixture, &["templates/**"]).await;
1246
1247 let path = fixture.write("templates/fresh.md", "- [ ] brand new template step\n");
1248 fixture
1249 .repo
1250 .invalidate_file(&path, &ChangeEventType::Created);
1251 index.invalidate_file(&path, &ChangeEventType::Created, &fixture.repo);
1252
1253 assert!(index.get(&path).is_none());
1254 assert_eq!(index.len(), 2);
1255 }
1256
1257 #[tokio::test]
1259 async fn rebuild_keeps_ignored_files_out() {
1260 let fixture = templates_repo();
1261 let index = built_index(&fixture, &["templates/**"]).await;
1262
1263 fixture.write("templates/added.md", "- [ ] another template step\n");
1264 fixture.repo.full_rescan();
1265 index.rebuild_if_built(&fixture.repo);
1266
1267 assert_eq!(
1268 indexed_paths(&index),
1269 vec!["docs/plan.md", "docs/templates/local.md"]
1270 );
1271 }
1272
1273 #[tokio::test]
1274 async fn snapshot_returns_every_indexed_file() {
1275 let fixture = repo_over(&[
1276 ("a.md", "- [ ] one\n"),
1277 ("docs/b.md", "- [ ] two\n"),
1278 ("docs/c.md", "no tasks\n"),
1279 ]);
1280 let index = Arc::new(TaskIndex::new(&[]));
1281 index
1282 .ensure_built(&fixture.repo)
1283 .await
1284 .expect("build succeeds");
1285
1286 let mut urls: Vec<String> = index
1287 .snapshot()
1288 .iter()
1289 .map(|f| f.url_path.clone())
1290 .collect();
1291 urls.sort();
1292 assert_eq!(urls, vec!["/a/", "/docs/b/"]);
1293 }
1294
1295 fn default_markers() -> Vec<String> {
1299 ["TK", "TODO", "FIXME", "XXX"]
1300 .iter()
1301 .map(|m| (*m).to_string())
1302 .collect()
1303 }
1304
1305 async fn index_with_markers(fixture: &TestRepo, markers: &[String]) -> Arc<TaskIndex> {
1307 let index = Arc::new(TaskIndex::with_markers(&[], markers));
1308 index
1309 .ensure_built(&fixture.repo)
1310 .await
1311 .expect("build succeeds");
1312 index
1313 }
1314
1315 fn entries(index: &TaskIndex, path: &Path) -> Vec<(u32, TaskKind)> {
1317 index
1318 .get(path)
1319 .map(|f| f.tasks.iter().map(|t| (t.line, t.kind)).collect())
1320 .unwrap_or_default()
1321 }
1322
1323 #[tokio::test]
1324 async fn markers_are_indexed_only_when_configured() {
1325 let fixture = repo_over(&[("notes.md", "- [ ] a task\n\nthe rest is TODO\n")]);
1326 let path = fixture.path("notes.md");
1327
1328 let off = Arc::new(TaskIndex::new(&[]));
1331 off.ensure_built(&fixture.repo)
1332 .await
1333 .expect("build succeeds");
1334 assert_eq!(entries(&off, &path), vec![(1, TaskKind::Task)]);
1335
1336 let empty = index_with_markers(&fixture, &[]).await;
1339 assert_eq!(entries(&empty, &path), vec![(1, TaskKind::Task)]);
1340
1341 let on = index_with_markers(&fixture, &default_markers()).await;
1342 assert_eq!(
1343 entries(&on, &path),
1344 vec![(1, TaskKind::Task), (3, TaskKind::Marker)]
1345 );
1346 }
1347
1348 #[tokio::test]
1349 async fn a_file_with_only_markers_is_indexed() {
1350 let fixture = repo_over(&[
1353 ("prose.md", "# Notes\n\nThe market fell 10% (source: TK).\n"),
1354 ("clean.md", "# Notes\n\nNothing outstanding here.\n"),
1355 ]);
1356 let index = index_with_markers(&fixture, &default_markers()).await;
1357
1358 assert_eq!(index.len(), 1, "only the file with a marker is stored");
1359 let stored = index.get(&fixture.path("prose.md")).expect("indexed");
1360 assert_eq!(stored.tasks.len(), 1);
1361 assert_eq!(stored.tasks[0].kind, TaskKind::Marker);
1362 assert_eq!(stored.tasks[0].text, "The market fell 10% (source: TK).");
1363 assert_eq!(stored.url_path, "/prose/");
1364 }
1365
1366 #[tokio::test]
1367 async fn markers_do_not_count_toward_progress() {
1368 let fixture = repo_over(&[(
1372 "notes.md",
1373 "- [ ] open one\n- [x] done one\nTODO: expand this\nand FIXME that\n",
1374 )]);
1375 let index = index_with_markers(&fixture, &default_markers()).await;
1376
1377 let stored = index.get(&fixture.path("notes.md")).expect("indexed");
1378 assert_eq!(stored.tasks.len(), 4, "all four entries are indexed");
1379 assert_eq!((stored.open, stored.done, stored.tracked()), (1, 1, 2));
1380 }
1381
1382 #[tokio::test]
1383 async fn invalidate_drops_a_file_that_lost_its_last_marker() {
1384 let fixture = repo_over(&[("prose.md", "the source for this is TK\n")]);
1388 let index = index_with_markers(&fixture, &default_markers()).await;
1389 assert_eq!(index.len(), 1);
1390
1391 let path = fixture.write("prose.md", "the source is Smith 2024\n");
1392 index.invalidate_file(&path, &ChangeEventType::Modified, &fixture.repo);
1393
1394 assert!(
1395 index.get(&path).is_none(),
1396 "a file with neither a task nor a marker must leave the index"
1397 );
1398 assert!(index.is_empty());
1399 }
1400}