use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use papaya::HashMap;
use serde::Serialize;
use crate::errors::TaskIndexError;
use crate::repo::{MarkdownInfo, Repo};
use crate::tasks::{MarkerRule, Task, TaskKind, TaskStatus, scan_source_tasks_with_markers};
use crate::watcher::ChangeEventType;
const MAX_SCAN_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FileTasks {
pub url_path: String,
pub raw_path: PathBuf,
pub title: Option<String>,
pub tasks: Vec<Task>,
pub open: u32,
pub done: u32,
#[serde(skip)]
folder: String,
}
impl FileTasks {
pub fn new(
url_path: impl Into<String>,
raw_path: impl Into<PathBuf>,
title: Option<String>,
tasks: Vec<Task>,
) -> Self {
let raw_path = raw_path.into();
let folder = folder_of(&raw_path);
let open = count_with(&tasks, TaskStatus::Open);
let done = count_with(&tasks, TaskStatus::Done);
Self {
url_path: url_path.into(),
raw_path,
title,
tasks,
open,
done,
folder,
}
}
pub fn folder(&self) -> &str {
&self.folder
}
pub fn display_title(&self) -> &str {
self.title.as_deref().unwrap_or_else(|| {
self.raw_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Untitled")
})
}
pub fn tracked(&self) -> u32 {
self.open + self.done
}
}
fn count_with(tasks: &[Task], status: TaskStatus) -> u32 {
u32::try_from(
tasks
.iter()
.filter(|t| t.kind == TaskKind::Task && t.status == status)
.count(),
)
.unwrap_or(u32::MAX)
}
fn folder_of(raw_path: &Path) -> String {
match raw_path.parent() {
Some(parent) if parent.as_os_str().is_empty() => "/".to_string(),
Some(parent) => format!("/{}/", crate::url_path::path_to_url(parent)),
None => "/".to_string(),
}
}
pub struct TaskIndex {
files: HashMap<PathBuf, Arc<FileTasks>>,
built: tokio::sync::OnceCell<()>,
ignore_globs: Vec<glob::Pattern>,
marker_rule: Option<MarkerRule>,
}
impl Default for TaskIndex {
fn default() -> Self {
Self::new(&[])
}
}
impl TaskIndex {
pub fn new(ignore_globs: &[String]) -> Self {
Self::with_markers(ignore_globs, &[])
}
pub fn with_markers(ignore_globs: &[String], markers: &[String]) -> Self {
Self {
files: HashMap::new(),
built: tokio::sync::OnceCell::new(),
ignore_globs: compile_ignore_globs(ignore_globs),
marker_rule: MarkerRule::new(markers),
}
}
fn is_ignored(&self, raw_path: &Path) -> bool {
if self.ignore_globs.is_empty() {
return false;
}
let relative = crate::url_path::path_to_url(raw_path);
self.ignore_globs
.iter()
.any(|pattern| pattern.matches(&relative))
}
pub fn is_built(&self) -> bool {
self.built.initialized()
}
pub fn len(&self) -> usize {
self.files.pin().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn get(&self, abs_path: &Path) -> Option<Arc<FileTasks>> {
self.files.pin().get(abs_path).cloned()
}
pub fn snapshot(&self) -> Vec<Arc<FileTasks>> {
self.files.pin().values().cloned().collect()
}
pub async fn ensure_built(
self: &Arc<Self>,
repo: &Arc<Repo>,
root_dir: &Path,
) -> Result<(), TaskIndexError> {
let index = Arc::clone(self);
let repo = Arc::clone(repo);
let root_dir = root_dir.to_path_buf();
self.built
.get_or_try_init(|| async move {
tokio::task::spawn_blocking(move || index.build_blocking(&repo, &root_dir))
.await
.map_err(|e| TaskIndexError::BuildFailed {
reason: e.to_string(),
})
})
.await
.map(|_| ())
}
pub fn invalidate_file(
&self,
abs_path: &Path,
event: &ChangeEventType,
repo: &Repo,
root_dir: &Path,
) {
if !self.is_built() {
return;
}
match event {
ChangeEventType::Deleted => {
self.files.pin().remove(abs_path);
}
ChangeEventType::Created | ChangeEventType::Modified => {
let Some(target) = repo
.markdown_files
.pin()
.get(abs_path)
.map(|info| ScanTarget::from_info(abs_path, info))
else {
self.files.pin().remove(abs_path);
return;
};
if self.is_ignored(&target.raw_path) {
self.files.pin().remove(abs_path);
return;
}
let mut buffer = String::new();
match target.scan(root_dir, &mut buffer, self.marker_rule.as_ref()) {
Some(file_tasks) => {
self.files
.pin()
.insert(abs_path.to_path_buf(), Arc::new(file_tasks));
}
None => {
self.files.pin().remove(abs_path);
}
}
}
}
}
pub fn rebuild_if_built(&self, repo: &Repo, root_dir: &Path) {
if !self.is_built() {
return;
}
let fresh = self.scan_all(repo, root_dir);
let files = self.files.pin();
let stale: Vec<PathBuf> = {
let fresh_paths: std::collections::HashSet<&PathBuf> =
fresh.iter().map(|(path, _)| path).collect();
files
.keys()
.filter(|key| !fresh_paths.contains(*key))
.cloned()
.collect()
};
for (path, file_tasks) in fresh {
files.insert(path, file_tasks);
}
for path in stale {
files.remove(&path);
}
}
fn build_blocking(&self, repo: &Repo, root_dir: &Path) {
let files = self.files.pin();
for (path, file_tasks) in self.scan_all(repo, root_dir) {
files.insert(path, file_tasks);
}
}
fn scan_all(&self, repo: &Repo, root_dir: &Path) -> Vec<(PathBuf, Arc<FileTasks>)> {
let start = std::time::Instant::now();
let targets: Vec<ScanTarget> = repo
.markdown_files
.pin()
.iter()
.filter(|(_, info)| !self.is_ignored(&info.raw_path))
.map(|(abs_path, info)| ScanTarget::from_info(abs_path, info))
.collect();
let total = targets.len();
let mut buffer = String::new();
let mut found = Vec::new();
for target in targets {
let scanned = target.scan(root_dir, &mut buffer, self.marker_rule.as_ref());
if let Some(file_tasks) = scanned {
found.push((target.abs_path, Arc::new(file_tasks)));
}
}
tracing::debug!(
"Task scan: {}/{total} files contain tasks ({:?})",
found.len(),
start.elapsed()
);
found
}
}
struct ScanTarget {
abs_path: PathBuf,
raw_path: PathBuf,
url_path: String,
title: Option<String>,
}
impl ScanTarget {
fn from_info(abs_path: &Path, info: &MarkdownInfo) -> Self {
Self {
abs_path: abs_path.to_path_buf(),
raw_path: info.raw_path.clone(),
url_path: info.url_path.clone(),
title: info
.frontmatter
.as_ref()
.and_then(|fm| fm.get("title"))
.and_then(|v| v.as_str())
.map(str::to_string),
}
}
fn scan(
&self,
root_dir: &Path,
buffer: &mut String,
markers: Option<&MarkerRule>,
) -> Option<FileTasks> {
let path = root_dir.join(&self.raw_path);
if let Err(e) = read_capped(&path, buffer) {
tracing::debug!("Task scan skipped {}: {e}", path.display());
return None;
}
let tasks = scan_source_tasks_with_markers(buffer, markers);
if tasks.is_empty() {
return None;
}
Some(FileTasks::new(
self.url_path.clone(),
self.raw_path.clone(),
self.title.clone(),
tasks,
))
}
}
fn compile_ignore_globs(patterns: &[String]) -> Vec<glob::Pattern> {
patterns
.iter()
.filter_map(|pattern| {
glob::Pattern::new(pattern)
.map_err(|e| tracing::warn!("Invalid tasks_ignore_globs pattern '{pattern}': {e}"))
.ok()
})
.collect()
}
fn read_capped(path: &Path, buffer: &mut String) -> std::io::Result<()> {
let mut file = File::open(path)?;
let len = file.metadata()?.len();
if len > MAX_SCAN_BYTES {
return Err(std::io::Error::other(format!(
"file is {len} bytes, over the {MAX_SCAN_BYTES}-byte task-scan limit"
)));
}
buffer.clear();
buffer.reserve(usize::try_from(len).unwrap_or(0));
file.read_to_string(buffer)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tasks::parse_task_line;
struct TestRepo {
repo: Arc<Repo>,
root: PathBuf,
_dir: tempfile::TempDir,
}
impl TestRepo {
fn path(&self, relative: &str) -> PathBuf {
self.root.join(relative)
}
fn write(&self, relative: &str, contents: &str) -> PathBuf {
let path = self.path(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dir");
}
std::fs::write(&path, contents).expect("write fixture file");
path
}
}
fn repo_over(files: &[(&str, &str)]) -> TestRepo {
let dir = tempfile::tempdir().expect("create temp dir");
let root = dir.path().canonicalize().expect("canonicalize temp dir");
for (rel, contents) in files {
let path = root.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent dir");
}
std::fs::write(&path, contents).expect("write fixture file");
}
let repo = Repo::init(
root.clone(),
"static",
&["md".to_string()],
&[],
&[],
"index.md",
&[],
&[],
);
repo.scan_all().expect("scan repo");
TestRepo {
repo: Arc::new(repo),
root,
_dir: dir,
}
}
fn task(line: &str) -> Task {
parse_task_line(line, 1).expect("fixture line is a task")
}
fn file_tasks(url: &str, raw: &str, lines: &[&str]) -> FileTasks {
FileTasks::new(
url,
PathBuf::from(raw),
None,
lines.iter().copied().map(task).collect(),
)
}
#[test]
fn counts_exclude_canceled_from_both() {
let file = file_tasks(
"/notes/",
"notes.md",
&[
"- [ ] open one",
"- [ ] open two",
"- [x] done one",
"- [-] canceled",
"- [>] moved > 2026-08-04",
],
);
assert_eq!(file.open, 2);
assert_eq!(file.done, 1);
assert_eq!(file.tracked(), 3);
assert_eq!(file.tasks.len(), 5);
}
#[test]
fn folder_comes_from_the_source_path_not_the_url() {
let note = file_tasks("/docs/notes/weekly/", "docs/notes/weekly.md", &["- [ ] a"]);
assert_eq!(note.folder(), "/docs/notes/");
let index = file_tasks("/docs/", "docs/index.md", &["- [ ] a"]);
assert_eq!(index.folder(), "/docs/");
let root = file_tasks("/readme/", "readme.md", &["- [ ] a"]);
assert_eq!(root.folder(), "/");
}
#[test]
fn display_title_falls_back_to_the_file_stem() {
let untitled = file_tasks("/docs/guide/", "docs/guide.md", &["- [ ] a"]);
assert_eq!(untitled.display_title(), "guide");
let titled = FileTasks::new(
"/docs/guide/",
PathBuf::from("docs/guide.md"),
Some("The Guide".to_string()),
vec![task("- [ ] a")],
);
assert_eq!(titled.display_title(), "The Guide");
}
#[test]
fn read_capped_reuses_the_buffer_without_appending() {
let dir = tempfile::tempdir().expect("temp dir");
let first = dir.path().join("a.md");
let second = dir.path().join("b.md");
std::fs::write(&first, "aaaa").expect("write");
std::fs::write(&second, "bb").expect("write");
let mut buffer = String::new();
read_capped(&first, &mut buffer).expect("read a");
assert_eq!(buffer, "aaaa");
read_capped(&second, &mut buffer).expect("read b");
assert_eq!(buffer, "bb", "buffer must be cleared between files");
}
#[test]
fn read_capped_refuses_oversized_files() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("huge.md");
let oversized = vec![b'x'; usize::try_from(MAX_SCAN_BYTES).unwrap_or(0) + 1];
std::fs::write(&path, &oversized).expect("write");
let mut buffer = String::new();
assert!(read_capped(&path, &mut buffer).is_err());
}
#[test]
fn read_capped_rejects_invalid_utf8() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("binary.md");
std::fs::write(&path, [0xff, 0xfe, 0x00]).expect("write");
let mut buffer = String::new();
assert!(read_capped(&path, &mut buffer).is_err());
}
#[tokio::test]
async fn index_is_not_built_until_first_use() {
let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
let index = Arc::new(TaskIndex::new(&[]));
assert!(!index.is_built());
assert!(index.is_empty());
index.invalidate_file(
&fixture.path("notes.md"),
&ChangeEventType::Modified,
&fixture.repo,
&fixture.root,
);
assert!(!index.is_built());
assert!(index.is_empty());
index.rebuild_if_built(&fixture.repo, &fixture.root);
assert!(!index.is_built());
assert!(index.is_empty());
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
assert!(index.is_built());
assert_eq!(index.len(), 1);
}
#[tokio::test]
async fn build_stores_only_files_that_contain_tasks() {
let fixture = repo_over(&[
("with.md", "# Notes\n\n- [ ] a task\n"),
("without.md", "# Notes\n\nJust prose.\n"),
("fenced.md", "```\n- [ ] not a task\n```\n"),
]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
assert_eq!(index.len(), 1);
let stored = index.get(&fixture.path("with.md")).expect("indexed");
assert_eq!(stored.tasks.len(), 1);
assert_eq!(stored.tasks[0].text, "a task");
assert_eq!(stored.url_path, "/with/");
assert_eq!(stored.raw_path, PathBuf::from("with.md"));
}
#[tokio::test]
async fn build_records_frontmatter_titles_and_counts() {
let fixture = repo_over(&[(
"docs/plan.md",
"---\ntitle: The Plan\n---\n\n- [ ] one\n- [x] two\n- [-] three\n",
)]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
let stored = index.get(&fixture.path("docs/plan.md")).expect("indexed");
assert_eq!(stored.title.as_deref(), Some("The Plan"));
assert_eq!(stored.display_title(), "The Plan");
assert_eq!((stored.open, stored.done), (1, 1));
assert_eq!(stored.folder(), "/docs/");
assert_eq!(stored.tasks[0].line, 5);
}
#[tokio::test]
async fn ensure_built_is_idempotent_and_single_flight() {
let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
let index = Arc::new(TaskIndex::new(&[]));
let calls = (0..8).map(|_| {
let index = Arc::clone(&index);
let repo = Arc::clone(&fixture.repo);
let root = fixture.root.clone();
async move { index.ensure_built(&repo, &root).await }
});
for result in futures::future::join_all(calls).await {
result.expect("build succeeds");
}
assert_eq!(index.len(), 1);
let stored = index.get(&fixture.path("notes.md")).expect("indexed");
assert_eq!(
stored.tasks.len(),
1,
"a re-run would have duplicated tasks"
);
}
#[tokio::test]
async fn invalidate_rescans_a_modified_file() {
let fixture = repo_over(&[("notes.md", "- [ ] before\n")]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
let path = fixture.write("notes.md", "- [x] after\n- [ ] and more\n");
index.invalidate_file(
&path,
&ChangeEventType::Modified,
&fixture.repo,
&fixture.root,
);
let stored = index.get(&path).expect("still indexed");
assert_eq!(stored.tasks.len(), 2);
assert_eq!(stored.tasks[0].text, "after");
assert_eq!((stored.open, stored.done), (1, 1));
}
#[tokio::test]
async fn invalidate_drops_a_file_that_lost_its_last_task() {
let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
assert_eq!(index.len(), 1);
let path = fixture.write("notes.md", "just prose now\n");
index.invalidate_file(
&path,
&ChangeEventType::Modified,
&fixture.repo,
&fixture.root,
);
assert!(
index.get(&path).is_none(),
"empty file must leave the index"
);
assert!(index.is_empty());
}
#[tokio::test]
async fn invalidate_removes_a_deleted_file() {
let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
let path = fixture.path("notes.md");
std::fs::remove_file(&path).expect("delete");
fixture
.repo
.invalidate_file(&path, &ChangeEventType::Deleted);
index.invalidate_file(
&path,
&ChangeEventType::Deleted,
&fixture.repo,
&fixture.root,
);
assert!(index.is_empty());
}
#[tokio::test]
async fn invalidate_indexes_a_newly_created_file() {
let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
let path = fixture.write("fresh.md", "---\ntitle: Fresh\n---\n\n- [ ] brand new\n");
fixture
.repo
.invalidate_file(&path, &ChangeEventType::Created);
index.invalidate_file(
&path,
&ChangeEventType::Created,
&fixture.repo,
&fixture.root,
);
let stored = index.get(&path).expect("indexed");
assert_eq!(stored.tasks[0].text, "brand new");
assert_eq!(stored.title.as_deref(), Some("Fresh"));
}
#[tokio::test]
async fn invalidate_ignores_files_the_repo_does_not_track() {
let fixture = repo_over(&[("notes.md", "- [ ] a task\n")]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
let asset = fixture.write("photo.png", "not markdown");
index.invalidate_file(
&asset,
&ChangeEventType::Created,
&fixture.repo,
&fixture.root,
);
assert_eq!(index.len(), 1);
assert!(index.get(&asset).is_none());
}
#[tokio::test]
async fn rebuild_refreshes_adds_and_prunes_in_one_pass() {
let fixture = repo_over(&[
("keep.md", "- [ ] unchanged\n"),
("change.md", "- [ ] before\n"),
("gone.md", "- [ ] doomed\n"),
]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
assert_eq!(index.len(), 3);
fixture.write("change.md", "- [x] after\n");
std::fs::remove_file(fixture.path("gone.md")).expect("delete");
fixture.write("added.md", "- [ ] newcomer\n");
fixture.repo.full_rescan();
index.rebuild_if_built(&fixture.repo, &fixture.root);
assert_eq!(index.len(), 3);
assert!(index.get(&fixture.path("gone.md")).is_none(), "pruned");
assert_eq!(
index
.get(&fixture.path("change.md"))
.expect("indexed")
.tasks[0]
.text,
"after",
"refreshed"
);
assert_eq!(
index.get(&fixture.path("added.md")).expect("indexed").tasks[0].text,
"newcomer",
"added"
);
assert!(index.get(&fixture.path("keep.md")).is_some(), "kept");
}
fn templates_repo() -> TestRepo {
repo_over(&[
("templates/checklist.md", "- [ ] template step\n"),
("templates/nested/deep.md", "- [ ] nested template step\n"),
("docs/templates/local.md", "- [ ] docs template step\n"),
("docs/plan.md", "- [ ] real work\n"),
])
}
fn indexed_paths(index: &TaskIndex) -> Vec<String> {
let mut paths: Vec<String> = index
.snapshot()
.iter()
.map(|f| crate::url_path::path_to_url(&f.raw_path))
.collect();
paths.sort();
paths
}
async fn built_index(fixture: &TestRepo, globs: &[&str]) -> Arc<TaskIndex> {
let globs: Vec<String> = globs.iter().map(|g| (*g).to_string()).collect();
let index = Arc::new(TaskIndex::new(&globs));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
index
}
#[test]
fn documented_pattern_semantics() {
let matches = |pattern: &str, path: &str| {
TaskIndex::new(&[pattern.to_string()]).is_ignored(Path::new(path))
};
assert!(matches("templates/**", "templates/a.md"));
assert!(matches("templates/**", "templates/deep/b.md"));
assert!(!matches("templates/**", "docs/templates/a.md"));
assert!(matches("**/templates/**", "templates/a.md"));
assert!(matches("**/templates/**", "docs/templates/a.md"));
assert!(matches("**/templates/**", "a/b/templates/c.md"));
assert!(!matches("**/templates/**", "templates.md"));
assert!(matches("docs/templates/**", "docs/templates/a.md"));
assert!(!matches("docs/templates/**", "templates/a.md"));
assert!(matches(
"**/*.checklist.md",
"templates/onboarding.checklist.md"
));
assert!(!matches("**/*.checklist.md", "templates/onboarding.md"));
assert!(!matches("templates", "templates/a.md"));
assert!(matches("templates/*.md", "templates/deep/a.md"));
}
#[tokio::test]
async fn ignore_globs_exclude_a_folder_and_everything_under_it() {
let fixture = templates_repo();
let index = built_index(&fixture, &["templates/**"]).await;
assert_eq!(
indexed_paths(&index),
vec!["docs/plan.md", "docs/templates/local.md"]
);
assert!(
index
.get(&fixture.path("templates/nested/deep.md"))
.is_none(),
"`**` must reach past the folder's direct children"
);
assert!(
index.get(&fixture.path("docs/plan.md")).is_some(),
"files outside the pattern must be untouched"
);
}
#[tokio::test]
async fn leading_double_star_matches_at_the_root_too() {
let fixture = templates_repo();
let index = built_index(&fixture, &["**/templates/**"]).await;
assert_eq!(indexed_paths(&index), vec!["docs/plan.md"]);
}
#[tokio::test]
async fn a_bare_folder_name_matches_nothing() {
let fixture = templates_repo();
let index = built_index(&fixture, &["templates"]).await;
assert_eq!(index.len(), 4, "a bare folder name excludes nothing");
}
#[tokio::test]
async fn no_ignore_globs_excludes_nothing() {
let fixture = templates_repo();
let index = built_index(&fixture, &[]).await;
assert_eq!(index.len(), 4);
}
#[tokio::test]
async fn a_malformed_pattern_is_dropped_not_applied() {
let fixture = templates_repo();
let index = built_index(&fixture, &["templates/**bad", "templates/**"]).await;
assert_eq!(
indexed_paths(&index),
vec!["docs/plan.md", "docs/templates/local.md"],
"the valid pattern still applies"
);
}
#[tokio::test]
async fn invalidate_does_not_readd_an_ignored_file() {
let fixture = templates_repo();
let index = built_index(&fixture, &["templates/**"]).await;
assert_eq!(index.len(), 2);
let path = fixture.write("templates/checklist.md", "- [ ] edited template step\n");
fixture
.repo
.invalidate_file(&path, &ChangeEventType::Modified);
index.invalidate_file(
&path,
&ChangeEventType::Modified,
&fixture.repo,
&fixture.root,
);
assert!(
index.get(&path).is_none(),
"editing an ignored file must not index it"
);
assert_eq!(index.len(), 2);
}
#[tokio::test]
async fn invalidate_does_not_add_a_newly_created_ignored_file() {
let fixture = templates_repo();
let index = built_index(&fixture, &["templates/**"]).await;
let path = fixture.write("templates/fresh.md", "- [ ] brand new template step\n");
fixture
.repo
.invalidate_file(&path, &ChangeEventType::Created);
index.invalidate_file(
&path,
&ChangeEventType::Created,
&fixture.repo,
&fixture.root,
);
assert!(index.get(&path).is_none());
assert_eq!(index.len(), 2);
}
#[tokio::test]
async fn rebuild_keeps_ignored_files_out() {
let fixture = templates_repo();
let index = built_index(&fixture, &["templates/**"]).await;
fixture.write("templates/added.md", "- [ ] another template step\n");
fixture.repo.full_rescan();
index.rebuild_if_built(&fixture.repo, &fixture.root);
assert_eq!(
indexed_paths(&index),
vec!["docs/plan.md", "docs/templates/local.md"]
);
}
#[tokio::test]
async fn snapshot_returns_every_indexed_file() {
let fixture = repo_over(&[
("a.md", "- [ ] one\n"),
("docs/b.md", "- [ ] two\n"),
("docs/c.md", "no tasks\n"),
]);
let index = Arc::new(TaskIndex::new(&[]));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
let mut urls: Vec<String> = index
.snapshot()
.iter()
.map(|f| f.url_path.clone())
.collect();
urls.sort();
assert_eq!(urls, vec!["/a/", "/docs/b/"]);
}
fn default_markers() -> Vec<String> {
["TK", "TODO", "FIXME", "XXX"]
.iter()
.map(|m| (*m).to_string())
.collect()
}
async fn index_with_markers(fixture: &TestRepo, markers: &[String]) -> Arc<TaskIndex> {
let index = Arc::new(TaskIndex::with_markers(&[], markers));
index
.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
index
}
fn entries(index: &TaskIndex, path: &Path) -> Vec<(u32, TaskKind)> {
index
.get(path)
.map(|f| f.tasks.iter().map(|t| (t.line, t.kind)).collect())
.unwrap_or_default()
}
#[tokio::test]
async fn markers_are_indexed_only_when_configured() {
let fixture = repo_over(&[("notes.md", "- [ ] a task\n\nthe rest is TODO\n")]);
let path = fixture.path("notes.md");
let off = Arc::new(TaskIndex::new(&[]));
off.ensure_built(&fixture.repo, &fixture.root)
.await
.expect("build succeeds");
assert_eq!(entries(&off, &path), vec![(1, TaskKind::Task)]);
let empty = index_with_markers(&fixture, &[]).await;
assert_eq!(entries(&empty, &path), vec![(1, TaskKind::Task)]);
let on = index_with_markers(&fixture, &default_markers()).await;
assert_eq!(
entries(&on, &path),
vec![(1, TaskKind::Task), (3, TaskKind::Marker)]
);
}
#[tokio::test]
async fn a_file_with_only_markers_is_indexed() {
let fixture = repo_over(&[
("prose.md", "# Notes\n\nThe market fell 10% (source: TK).\n"),
("clean.md", "# Notes\n\nNothing outstanding here.\n"),
]);
let index = index_with_markers(&fixture, &default_markers()).await;
assert_eq!(index.len(), 1, "only the file with a marker is stored");
let stored = index.get(&fixture.path("prose.md")).expect("indexed");
assert_eq!(stored.tasks.len(), 1);
assert_eq!(stored.tasks[0].kind, TaskKind::Marker);
assert_eq!(stored.tasks[0].text, "The market fell 10% (source: TK).");
assert_eq!(stored.url_path, "/prose/");
}
#[tokio::test]
async fn markers_do_not_count_toward_progress() {
let fixture = repo_over(&[(
"notes.md",
"- [ ] open one\n- [x] done one\nTODO: expand this\nand FIXME that\n",
)]);
let index = index_with_markers(&fixture, &default_markers()).await;
let stored = index.get(&fixture.path("notes.md")).expect("indexed");
assert_eq!(stored.tasks.len(), 4, "all four entries are indexed");
assert_eq!((stored.open, stored.done, stored.tracked()), (1, 1, 2));
}
#[tokio::test]
async fn invalidate_drops_a_file_that_lost_its_last_marker() {
let fixture = repo_over(&[("prose.md", "the source for this is TK\n")]);
let index = index_with_markers(&fixture, &default_markers()).await;
assert_eq!(index.len(), 1);
let path = fixture.write("prose.md", "the source is Smith 2024\n");
index.invalidate_file(
&path,
&ChangeEventType::Modified,
&fixture.repo,
&fixture.root,
);
assert!(
index.get(&path).is_none(),
"a file with neither a task nor a marker must leave the index"
);
assert!(index.is_empty());
}
}