Skip to main content

mbr_core/
task_index.rs

1//! Lazy, in-memory index of the tasks contained in a repository's markdown.
2//!
3//! The index is **built on first use, never on startup**, and there is no
4//! on-disk cache: mbr is pointed at live directories that change underneath it,
5//! so the only trustworthy cache is one that a process restart discards.
6//!
7//! ```text
8//! first POST /.mbr/tasks ──► ensure_built() ──► one sequential read pass
9//!                                 │                over repo.markdown_files
10//!                                 ▼
11//!                        papaya map, keyed by absolute path,
12//!                        holding only files that contain tasks
13//!                                 ▲
14//! watcher reconciliation ─────────┘  invalidate_file() re-scans one file
15//! ```
16//!
17//! # Why sequential, not rayon
18//!
19//! [`TaskIndex::ensure_built`] reads every markdown file one after another on a
20//! single `spawn_blocking` thread. That is deliberate and follows the precedent
21//! set in `src/search.rs` (see the comments at `search.rs:362` and
22//! `search.rs:658`): `par_iter` over file reads caused 30-second stalls from
23//! rayon thread-pool contention when several requests ran at once. The repo
24//! scanner owns the rayon pool; request-path work stays off it.
25
26use 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
41/// Largest markdown file the task scanner will read, in bytes.
42///
43/// Task scanning needs the *whole* file (unlike frontmatter extraction, which
44/// reads the first 8 KB), and the index build touches every markdown file in
45/// the repository. A pathological multi-hundred-megabyte "markdown" file —
46/// a generated log, a dumped dataset — would otherwise stall the build and
47/// balloon memory for a file no human is tracking tasks in. Four megabytes is
48/// roughly a million lines of prose; nothing hand-written comes close.
49const MAX_SCAN_BYTES: u64 = 4 * 1024 * 1024;
50
51/// The tasks found in one markdown file, plus the metadata a task list needs to
52/// display and link to them.
53///
54/// Constructed only through [`FileTasks::new`] so that `open`/`done` can never
55/// drift from `tasks`.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57pub struct FileTasks {
58    /// Canonical site URL of the page, e.g. `/docs/notes/weekly/`.
59    pub url_path: String,
60    /// Source path **relative to the repository root**, mirroring
61    /// [`MarkdownInfo::raw_path`]. Join it with the root before any file I/O.
62    pub raw_path: PathBuf,
63    /// Frontmatter `title`, if the file declares one.
64    ///
65    /// `None` is not a display fallback — it means "this file has no declared
66    /// title", and callers fall back to the file stem exactly as
67    /// `markdown_file_to_json` does.
68    pub title: Option<String>,
69    /// Every task in the file, in source order.
70    pub tasks: Vec<Task>,
71    /// Count of [`TaskStatus::Open`] tasks.
72    pub open: u32,
73    /// Count of [`TaskStatus::Done`] tasks.
74    ///
75    /// Canceled tasks are excluded from **both** counts, so `open + done` is
76    /// the denominator of the "3/7 done" progress indicator.
77    pub done: u32,
78    /// URL-shaped folder of the file, with leading and trailing slashes
79    /// (`/docs/notes/`, or `/` at the repository root).
80    ///
81    /// Derived from `raw_path`, not from `url_path`: an index file's `url_path`
82    /// *is* its folder (`docs/index.md` → `/docs/`), so trimming the last URL
83    /// segment would file its tasks one level too high.
84    #[serde(skip)]
85    folder: String,
86}
87
88impl FileTasks {
89    /// Builds a `FileTasks`, deriving the counts and the folder from the inputs.
90    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    /// The file's folder as a URL path with leading and trailing slashes.
112    pub fn folder(&self) -> &str {
113        &self.folder
114    }
115
116    /// Display title: the frontmatter title, else the file stem.
117    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    /// Tasks that count toward the progress indicator (`open + done`).
127    pub fn tracked(&self) -> u32 {
128        self.open + self.done
129    }
130}
131
132/// Counts **checkbox** tasks with the given status, saturating rather than
133/// wrapping.
134///
135/// Incomplete markers are excluded, and that exclusion is what keeps the
136/// progress indicator honest. A marker is always `Open` and can never be
137/// completed, so counting one would put a note that merely says `TODO: expand
138/// this` at a permanent `0/1`, and would inflate the open count of every note
139/// containing the word — numbers no reader could ever move.
140fn 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
150/// URL-shaped folder for a repo-relative source path.
151///
152/// `docs/notes/weekly.md` → `/docs/notes/`, `readme.md` → `/`.
153fn folder_of(raw_path: &Path) -> String {
154    match raw_path.parent() {
155        Some(parent) if parent.as_os_str().is_empty() => "/".to_string(),
156        // `path_to_url` keeps the value `/`-separated on Windows, where
157        // `raw_path` uses backslashes.
158        Some(parent) => format!("/{}/", crate::url_path::path_to_url(parent)),
159        None => "/".to_string(),
160    }
161}
162
163/// Lazy, thread-safe index of every markdown file that contains tasks.
164///
165/// Files with neither a task nor a marker are deliberately **absent** rather
166/// than stored empty, which keeps both memory and every query's iteration
167/// proportional to the notes that actually have something to show. How large a
168/// share of a repository that is depends on the configuration: with markers
169/// enabled a note needs only the word `TODO` in a sentence to qualify, so the
170/// index is no longer the small minority of files it is with checkboxes alone.
171///
172/// Files matching `tasks_ignore_globs` are absent too. Excluding them *here*,
173/// at the one place the index is filled, is what makes the exclusion complete:
174/// the panel, the folder facets, `total_matches` and every count all derive
175/// from [`TaskIndex::snapshot`].
176pub struct TaskIndex {
177    /// Keyed by absolute path, matching [`Repo::markdown_files`] so the
178    /// watcher's canonical paths address the same entries.
179    files: HashMap<PathBuf, Arc<FileTasks>>,
180    /// Single-flight guard for the one-time build.
181    ///
182    /// `tokio::sync::OnceCell::get_or_try_init` leaves the cell *uninitialized*
183    /// when the initializer returns an error, so a failed build is retried by
184    /// the next caller instead of poisoning the index for the life of the
185    /// process.
186    built: tokio::sync::OnceCell<()>,
187    /// `tasks_ignore_globs`, compiled once at construction. See
188    /// [`TaskIndex::is_ignored`].
189    ignore_globs: Vec<glob::Pattern>,
190    /// `incomplete_markers`, compiled once at construction, or `None` when
191    /// marker scanning is off. See [`TaskIndex::with_markers`].
192    marker_rule: Option<MarkerRule>,
193}
194
195impl Default for TaskIndex {
196    fn default() -> Self {
197        Self::new(&[])
198    }
199}
200
201impl TaskIndex {
202    /// Creates an empty, unbuilt index that excludes files matching
203    /// `ignore_globs` (the `tasks_ignore_globs` config option).
204    ///
205    /// The patterns are compiled **here, once**, mirroring `Repo::init`'s
206    /// `compiled_ignore_globs`: the build walks every markdown file in the
207    /// repository, so re-parsing the patterns per file would cost far more than
208    /// the matching itself.
209    ///
210    /// A pattern that does not compile is dropped with a warning rather than
211    /// refused. [`Config::validate`](crate::config::Config::validate) already
212    /// rejects one at startup, so the only way to reach here with a bad pattern
213    /// is to bypass config loading entirely.
214    pub fn new(ignore_globs: &[String]) -> Self {
215        Self::with_markers(ignore_globs, &[])
216    }
217
218    /// [`TaskIndex::new`], additionally indexing the incomplete markers in
219    /// `markers` (the `incomplete_markers` config option).
220    ///
221    /// An **empty slice disables marker scanning**, the same equivalence
222    /// `incomplete_markers = []` already has for the renderer — which is also
223    /// how a caller says "highlighting is off", since a marker with no
224    /// highlight has no `#mbr-marker-N` anchor for the panel to link to.
225    ///
226    /// The rule is compiled **here, once**, for the reason the ignore globs are:
227    /// the build reads every markdown file in the repository, so re-compiling a
228    /// regex per file would dwarf the matching. [`MarkerRule`] is `Send + Sync`
229    /// (it wraps a `Regex`), which is what lets the whole index be shared as an
230    /// `Arc` into `spawn_blocking`.
231    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    /// Whether `tasks_ignore_globs` excludes this repository-relative path.
241    ///
242    /// Matching is against the `/`-separated relative path
243    /// (`docs/templates/onboarding.md`), not the absolute one: a pattern must
244    /// mean the same thing wherever the repository happens to sit on disk, and
245    /// on every platform — [`MarkdownInfo::raw_path`] uses `\` on Windows, which
246    /// `glob::Pattern` would never match against a `/`-shaped pattern.
247    ///
248    /// Returns before allocating when nothing is configured, which is the
249    /// default and therefore the hot path.
250    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    /// Whether the one-time build has completed.
261    pub fn is_built(&self) -> bool {
262        self.built.initialized()
263    }
264
265    /// Number of indexed files (only files that contain tasks).
266    pub fn len(&self) -> usize {
267        self.files.pin().len()
268    }
269
270    /// Whether the index currently holds no files.
271    pub fn is_empty(&self) -> bool {
272        self.len() == 0
273    }
274
275    /// The tasks recorded for one absolute path, if any.
276    pub fn get(&self, abs_path: &Path) -> Option<Arc<FileTasks>> {
277        self.files.pin().get(abs_path).cloned()
278    }
279
280    /// A cheap snapshot of every indexed file, for querying.
281    ///
282    /// Clones `Arc`s, not data. Taking a snapshot rather than querying through
283    /// a pin guard keeps the guard off the (potentially slow) grouping pass and
284    /// gives the query a stable view even if the watcher fires mid-query.
285    pub fn snapshot(&self) -> Vec<Arc<FileTasks>> {
286        self.files.pin().values().cloned().collect()
287    }
288
289    /// Builds the index if it has not been built yet.
290    ///
291    /// Idempotent and single-flight: concurrent callers await one build. The
292    /// work runs on `spawn_blocking` because it is a sequential pass of
293    /// synchronous file reads.
294    ///
295    /// Individual unreadable files (permissions, invalid UTF-8, oversized) are
296    /// skipped with a debug log; only a panic in the build task is an error.
297    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    /// Re-scans (Created/Modified) or drops (Deleted) one file.
314    ///
315    /// **No-op when the index has never been built.** A change arriving before
316    /// anybody asked for tasks must not trigger the very build the laziness
317    /// exists to avoid.
318    ///
319    /// NOTE (known window): a file changed *while* the initial build is running
320    /// is skipped here, because the build has not published itself yet, and the
321    /// build may already have read that file's previous contents. The entry
322    /// then stays stale until the file changes again or the process restarts.
323    /// Closing it would mean either serializing the watcher against the build
324    /// (blocking edits behind a full-repo read pass) or queueing deferred
325    /// invalidations forever for the common case where tasks are never opened.
326    /// Neither is worth it for a window that is bounded by one build.
327    ///
328    /// Call this *after* [`Repo::invalidate_file`] for the same event: the
329    /// url/title of a created or modified file are read back out of the repo.
330    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                // Absent from the repo means "not a markdown file the repo
341                // tracks" — the watcher forwards create/delete events for
342                // assets too — or a file deleted between the two calls.
343                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                // The same guard `scan_all` applies, repeated because this is a
354                // separate path into the map: without it, editing a file in an
355                // ignored folder would quietly index the very file the build
356                // skipped.
357                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                // Borrowed, not cloned: one compiled rule serves every scan.
364                match target.scan(
365                    repo.vault().as_ref(),
366                    &mut buffer,
367                    self.marker_rule.as_ref(),
368                ) {
369                    // A file that lost its last task must leave the index, or
370                    // it lingers as an empty group forever.
371                    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    /// Re-scans the whole repository, but only if the index was already built.
385    ///
386    /// The watcher calls this on its full-rescan path (a change batch too large
387    /// to patch file by file), where per-file invalidation would cost more than
388    /// one read pass. Like [`Self::invalidate_file`] it does nothing when the
389    /// index has never been built, so an unused task index stays free.
390    ///
391    /// Entries are replaced and stale ones pruned in a single pass at the end,
392    /// so a concurrent query never observes a half-empty index — only the old
393    /// contents or the new ones.
394    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        // Set membership, not a nested scan: a linear search per key would be
402        // quadratic in the number of task-bearing files.
403        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    /// Fills an empty index. See the module docs for why this is not parallel.
422    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    /// The one sequential read pass: every markdown file the repo knows about,
430    /// read once, keeping only those that contain tasks.
431    fn scan_all(&self, repo: &Repo) -> Vec<(PathBuf, Arc<FileTasks>)> {
432        let start = std::time::Instant::now();
433
434        // Snapshot the file list before touching the filesystem: holding the
435        // pin guard across thousands of blocking reads would pin the map's
436        // epoch for the whole pass. `search.rs` collects first for the same
437        // reason.
438        let targets: Vec<ScanTarget> = repo
439            .markdown_files
440            .pin()
441            .iter()
442            // Filtered before the read, not after: an excluded file is never
443            // opened, so a large template folder costs nothing to skip.
444            .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        // One buffer reused across every file; `scan` clears it per read.
451        let mut buffer = String::new();
452        let mut found = Vec::new();
453
454        for target in targets {
455            // Bound before the `if let` so the borrow of `target` has ended by
456            // the time its `abs_path` is moved out.
457            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
472/// One file the scanner has to read, decoupled from the repo's pin guard.
473struct 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    /// Reads and scans the file, returning `None` when it cannot be read or
496    /// contains nothing to index.
497    ///
498    /// `markers` is the index's compiled [`MarkerRule`], borrowed rather than
499    /// cloned so a repository-wide pass compiles nothing per file.
500    fn scan(
501        &self,
502        vault: &dyn Vault,
503        buffer: &mut String,
504        markers: Option<&MarkerRule>,
505    ) -> Option<FileTasks> {
506        // `raw_path` is repo-relative: it names the file inside the vault —
507        // the same addressing `SearchEngine::search_file_content` uses. The
508        // map key would also work, but going through `raw_path` keeps this
509        // identical to the rest of the codebase's file access.
510        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        // Tasks and markers come back merged, in source order, which is why the
518        // emptiness test below needs no change: "no tasks *and* no markers" is
519        // one question about one Vec.
520        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
533/// Compiles `tasks_ignore_globs`, skipping any pattern that does not parse.
534///
535/// Lenient by design, and safe to be: [`Config::validate`](crate::config::Config::validate)
536/// refuses a malformed pattern before a server ever starts, so this only
537/// forgives callers that construct a `TaskIndex` without loading config.
538fn 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
549/// [`read_capped`] for a file in a vault.
550///
551/// The fast path is the local file itself, read into the reused buffer as
552/// before. Otherwise the size is checked from a `stat` (so an oversized file
553/// is refused without being fetched) and the bytes come through the vault.
554fn 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
575/// The error for a file over [`MAX_SCAN_BYTES`].
576fn 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
582/// Reads a whole file into `buffer`, refusing anything over [`MAX_SCAN_BYTES`].
583///
584/// The size check uses the already-open handle's metadata, so it costs no extra
585/// path lookup. Invalid UTF-8 is an error rather than a lossy decode: a file
586/// that is not text has no tasks in it worth guessing at.
587fn 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    // `read_to_string` appends, and the buffer was just cleared, so this reads
596    // the whole file exactly once with at most one growth.
597    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    /// A scanned repository over `(relative path, contents)` pairs, plus the
608    /// root to address it by.
609    struct TestRepo {
610        repo: Arc<Repo>,
611        /// The **canonical** root. `Repo` canonicalizes the root it is given and
612        /// keys `markdown_files` by canonical absolute paths, and on macOS a
613        /// temp dir differs from its canonical form (`/var` vs `/private/var`).
614        /// Addressing the index by the raw `TempDir` path would miss every
615        /// entry — the same trap `server.rs` documents for the watcher.
616        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    // ---- FileTasks -----------------------------------------------------------
678
679    #[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        // The progress denominator is open + done: the two canceled tasks are
695        // invisible to it.
696        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        // A regular note: folder is its parent directory.
703        let note = file_tasks("/docs/notes/weekly/", "docs/notes/weekly.md", &["- [ ] a"]);
704        assert_eq!(note.folder(), "/docs/notes/");
705
706        // An index file's url_path *is* the folder, so deriving the folder from
707        // the URL would file its tasks under `/` instead of `/docs/`.
708        let index = file_tasks("/docs/", "docs/index.md", &["- [ ] a"]);
709        assert_eq!(index.folder(), "/docs/");
710
711        // Root-level file.
712        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    // ---- non-local vaults ----------------------------------------------------
731
732    /// A scanned repository in a `MemVault`, which has no `local_path`.
733    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    /// Build, invalidation and the size cap all work through the vault when
750    /// there is no file to open.
751    #[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        // Oversized files are refused from the `stat`, before any read.
775        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    /// A note that is not downloaded is skipped, not an error, and the rest of
784    /// the build completes.
785    #[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    // ---- read_capped ---------------------------------------------------------
798
799    #[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    // ---- build ---------------------------------------------------------------
836
837    #[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        // Constructing the index reads nothing.
843        assert!(!index.is_built());
844        assert!(index.is_empty());
845
846        // Neither does a file change: invalidation must not become a build.
847        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        // Nor does a full-rescan notification.
856        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        // Line numbers are 1-based over the whole file, frontmatter included.
907        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        // Concurrent callers must converge on one build, not N.
916        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    // ---- invalidation --------------------------------------------------------
935
936    #[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        // Mirrors the watcher: the repo learns about the file first.
1004        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        // The watcher forwards create/delete for assets too; those must not
1024        // land in a *task* index.
1025        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    // ---- full rescan ---------------------------------------------------------
1033
1034    #[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        // A change batch too large for surgical invalidation: edit, delete and
1049        // add all at once, then rescan wholesale.
1050        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    // ---- tasks_ignore_globs --------------------------------------------------
1076
1077    /// A `templates/` folder at the root, a second one nested under `docs/`,
1078    /// and one file that is nobody's template.
1079    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    /// Every indexed file's repo-relative path, sorted.
1089    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    /// The pattern table in `docs/reference/configuration.md#task-settings`,
1110    /// pinned so the documentation cannot drift from the matcher.
1111    #[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        // `templates/**` — anchored at the root, reaching all the way down.
1118        assert!(matches("templates/**", "templates/a.md"));
1119        assert!(matches("templates/**", "templates/deep/b.md"));
1120        assert!(!matches("templates/**", "docs/templates/a.md"));
1121
1122        // `**/templates/**` — any depth, including the root: `**/` matches zero
1123        // leading components.
1124        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        // A literal path excludes exactly one folder.
1130        assert!(matches("docs/templates/**", "docs/templates/a.md"));
1131        assert!(!matches("docs/templates/**", "templates/a.md"));
1132
1133        // Suffix patterns.
1134        assert!(matches(
1135            "**/*.checklist.md",
1136            "templates/onboarding.checklist.md"
1137        ));
1138        assert!(!matches("**/*.checklist.md", "templates/onboarding.md"));
1139
1140        // Patterns name files, so a bare folder name excludes nothing.
1141        assert!(!matches("templates", "templates/a.md"));
1142
1143        // A single `*` is not stopped by `/` — mbr's globs behave this way
1144        // everywhere, and the docs say so.
1145        assert!(matches("templates/*.md", "templates/deep/a.md"));
1146    }
1147
1148    /// `templates/**` is anchored at the repository root and reaches all the way
1149    /// down, so the nested file goes too — but a same-named folder elsewhere
1150    /// stays.
1151    #[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    /// The other documented spelling: `**/templates/**` matches a `templates`
1173    /// folder at *any* depth, including the repository root — `**/` matches zero
1174    /// leading components.
1175    #[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    /// Documented semantics, pinned: patterns match a *file* path, so a bare
1184    /// folder name matches nothing. `templates/**` is the spelling that works.
1185    #[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    /// The default: no patterns, nothing excluded.
1194    #[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    /// A malformed pattern is dropped rather than treated as a literal, so it
1203    /// cannot accidentally exclude something. `Config::validate` rejects it
1204    /// before a real server ever gets here.
1205    #[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    /// The guard `invalidate_file` needs of its own: it is a second way into the
1218    /// map, so without it, editing a file in an ignored folder silently indexes
1219    /// exactly the file the build skipped.
1220    #[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        // Mirrors the watcher: the repo is invalidated first.
1228        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    /// Creating a file inside an ignored folder is the same story through the
1241    /// `Created` arm.
1242    #[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    /// The full-rescan path shares `scan_all`, so it inherits the filter.
1258    #[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    // ---- incomplete markers --------------------------------------------------
1296
1297    /// The shipped `incomplete_markers` default, owned for the constructor.
1298    fn default_markers() -> Vec<String> {
1299        ["TK", "TODO", "FIXME", "XXX"]
1300            .iter()
1301            .map(|m| (*m).to_string())
1302            .collect()
1303    }
1304
1305    /// Builds and returns an index over `fixture` with the given markers.
1306    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    /// Every indexed entry of one file, as `(line, kind)`.
1316    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        // `new` — and therefore the `Default` impl — leaves marker scanning off,
1329        // which is what keeps every existing caller on the old behaviour.
1330        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        // An empty marker list is the same off switch, exactly as
1337        // `incomplete_markers = []` is for the renderer.
1338        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        // The whole point of the feature: a note with no checkbox in it is
1351        // still somewhere somebody left work unfinished.
1352        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        // A marker can never be completed, so counting one would peg this note
1369        // below 100% forever and inflate the open count of every note that
1370        // happens to contain the word.
1371        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        // The marker half of `invalidate_drops_a_file_that_lost_its_last_task`:
1385        // one merged Vec means "no tasks *and* no markers" is one emptiness
1386        // test, so a file that stops qualifying leaves by the same path.
1387        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}