lattice_multibuffer/providers/scan_view.rs
1//! OM.A1 (2026-08-25): the **agenda provider** — a date-grouped multibuffer
2//! of rows contributed by plugin `scanned-excerpt-source` producers.
3//!
4//! Design: [`org-mode.md`](../../../../docs/dev/architecture/org-mode.md) §6.
5//! Slice plan: `slice-plans/org-mode.md` OM.A1–A3.
6//!
7//! ## Nothing here knows what an org file is
8//!
9//! That is the point. The provider walks the project, asks the
10//! [`ScannedExcerptSourceRegistry`](lattice_mode::ScannedExcerptSourceRegistry) which sources
11//! claim each file's extension, and hands the matching ones its text. Which
12//! extensions those are, what a headline is, what `SCHEDULED:` means, how a
13//! date sorts — all of it lives in the guest. The host contributes the walk,
14//! the ordering primitive, and the multibuffer.
15//!
16//! ## Why a multibuffer and not a rendered list
17//!
18//! An agenda row IS an excerpt: `Excerpt { source, start_line, end_line,
19//! header }` (§6.1). Taking that seriously buys jump-to-source,
20//! edit-propagates-to-source, headerline status and refresh from machinery
21//! that already ships. The second of those is the one that decided it — org's
22//! agenda is a place you change TODO states *from*, and an agenda you can
23//! only read is a lesser feature wearing the name.
24//!
25//! ## Why the whole scan finishes before anything is appended
26//!
27//! Unlike `providers::search`, the agenda's row order is **global**: a row
28//! from the last file scanned may belong at the top. So the scan collects,
29//! stable-sorts on the guest's `sort_key`, and appends once. Progress is not
30//! lost — it moves to the headerline, which reports files scanned as the walk
31//! runs (§6.2). Appending per file and re-sorting per batch was rejected:
32//! rewriting every row on each batch is a whole-viewport restyle, which the
33//! UX rules veto outright.
34
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, RwLock};
38
39use lattice_core::{BufferFlags, BufferId, DocumentBuilder};
40use lattice_grammar::{Args, CommandRegistry, CommandRegistryHandle};
41use lattice_mode::{
42 ModeActivator, ProviderViewOutcome, ScannedExcerpt, ScannedExcerptSource,
43 ScannedExcerptSourceRegistryHandle, ServiceRegistry,
44};
45use lattice_runtime::{Document, EventBus, spawn_document};
46
47use crate::events::MultibufferExcerptsReady;
48use crate::registry::MultibufferRegistryHandle;
49use crate::view::create_multibuffer_view;
50use crate::{Excerpt, ExcerptHeader, ExcerptHeaderStyle, HeaderlineStatus};
51
52/// How many files one `spawn_blocking` hop reads. Big enough that the
53/// blocking-pool round-trip is amortised, small enough that a huge project
54/// still updates the headerline while it walks.
55const READ_BATCH: usize = 32;
56
57/// Update the headerline every this many files.
58const PROGRESS_INTERVAL: usize = 50;
59
60// ─────────────────────────────────────────────────────────────────
61// Scan parameters + per-view state
62// ─────────────────────────────────────────────────────────────────
63
64/// What a scan walks.
65#[derive(Debug, Clone, Default)]
66pub struct ScanViewOptions {
67 /// AF.2: the paths to scan — each a FILE or a DIRECTORY. A directory is
68 /// walked; a file is taken as given, without asking whether any source
69 /// claimed its extension, because naming a file IS the claim.
70 ///
71 /// **Empty means "not resolved yet", not "scan nothing".** The scan then
72 /// asks every live source for its own roots and falls back to the project
73 /// root if none answers. It cannot be resolved here, because a source's
74 /// answer comes from the guest and the opener runs on the dispatch path.
75 ///
76 /// That is why `Default` is now empty where it used to be the project
77 /// root: the fallback moved to the one place that can see every source's
78 /// answer, rather than being baked in before any of them was asked.
79 pub roots: Vec<PathBuf>,
80 /// OA.11a: the view's own scan arguments, handed to every source's `begin`
81 /// **uninterpreted**. The host routes these; it never reads them.
82 ///
83 /// Separate from `roots` because they have separate owners. The host must
84 /// understand a root — it does the walk — and must not need to understand
85 /// this: it is the source's own vocabulary (org's agenda dispatcher names
86 /// which custom command to run). Folding the two together means a command
87 /// key gets taken for a directory path and the scan silently covers
88 /// nothing.
89 ///
90 /// Sticky across a refresh exactly as `roots` is, and for the same reason:
91 /// `gr` re-enters through this path, so an agenda opened for one command
92 /// must not quietly revert to the default one. Replaced only when a new
93 /// open supplies its own.
94 pub scan_args: Vec<String>,
95 /// Cap on files *offered to a source* (not files walked). `None` =
96 /// unlimited. A bound exists because the walk is unattended: a user who
97 /// runs the agenda from `$HOME` should get a slow answer, not a hung one.
98 ///
99 /// Applies to the UNION across roots, not per root — a cap that reset for
100 /// each configured path would not be a bound.
101 pub max_files: Option<usize>,
102}
103
104impl ScanViewOptions {
105 /// The directory a view's `:files` / `:search` should answer for.
106 ///
107 /// The first configured root when there is one, else the project root. A
108 /// source-supplied root cannot land here: the scope is set when the view
109 /// opens and those are resolved in the scan. Recorded rather than papered
110 /// over — the consequence is that `:files` from an agenda opened with no
111 /// argument answers for the project, which is also what it did before AF.2.
112 fn scope_dir(&self) -> PathBuf {
113 self.roots
114 .first()
115 .cloned()
116 .unwrap_or_else(project_root_from_cwd)
117 }
118}
119
120/// The root the agenda falls back to when nothing else names one.
121fn project_root_from_cwd() -> PathBuf {
122 lattice_core::project::root_from_cwd().unwrap_or_else(|| PathBuf::from("."))
123}
124
125/// Per-view scan state. OM.A3's `gr` reads the root back out of this so a
126/// refresh re-scans what the view already shows rather than resetting to the
127/// current working directory.
128#[derive(Debug, Clone)]
129pub struct ScanViewState {
130 /// The provider name this view was opened under.
131 ///
132 /// `gr` re-opens the view by name, and the name is the VIEW's, not this
133 /// module's: a scan view declared by a plugin is called whatever that
134 /// plugin called it. Before this the refresh handler re-emitted a
135 /// hardcoded `"agenda"`, so a refresh in any other scan view would have
136 /// gone to org's — which is the sort of thing a generic mode with an
137 /// org-shaped constant in it does.
138 pub provider: String,
139 pub options: ScanViewOptions,
140 /// OA.14b: every clocked span the last completed scan saw, across every
141 /// file and source, unfiltered by date.
142 ///
143 /// Held per view rather than recomputed because the report's RANGE is a
144 /// display choice — `gD` switches between day, week, month and year — and
145 /// re-walking the corpus to answer a question the data already contains
146 /// would make a toggle cost a scan. `org-agenda-clockreport-mode` (OA.16)
147 /// filters on `day` and rolls the totals up each span's outline path.
148 ///
149 /// Written once at the end of a scan, not incrementally: a half-filled
150 /// report is a wrong report, and unlike rows — which stream so the view
151 /// fills in — nobody is reading a total until it is a total.
152 pub clock: Vec<lattice_mode::ClockSpan>,
153 /// HB.5: the rows a scan asked to hang below its own, already translated
154 /// into COMPOSED coordinates.
155 ///
156 /// Translated at publish rather than at paint, for `composed_row_spans`'
157 /// reason: a row's composed index is not its index among its own file's
158 /// rows — the sort interleaves files — and the arithmetic belongs where the
159 /// excerpt layout is in hand, not in a provider that would have to
160 /// reconstruct it.
161 pub annotations: Vec<RowAnnotationAt>,
162 /// Bumped whenever [`annotations`](Self::annotations) is written, so the
163 /// cells worker re-runs the provider's `collect` without waiting for the
164 /// document's line count to move. A refresh that returns the same NUMBER
165 /// of rows with different annotations is the case this exists for.
166 pub annotations_version: u64,
167}
168
169/// HB.5: one annotation, and the composed row it hangs below.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct RowAnnotationAt {
172 /// The composed row of the entry this belongs to — the virtual row paints
173 /// immediately below it.
174 pub row: u32,
175 pub annotation: lattice_mode::RowAnnotation,
176}
177
178/// Per-view state, shared between the trigger and the scan task.
179pub trait ScanViewService: Send + Sync + std::fmt::Debug {
180 fn state(&self, view: BufferId) -> Option<Arc<RwLock<ScanViewState>>>;
181 fn set_state(&self, view: BufferId, state: ScanViewState);
182 fn clear(&self, view: BufferId);
183}
184
185/// Register **and** look up with this exact alias (the `ServiceRegistry`
186/// `TypeId` rule).
187pub type ScanViewServiceHandle = Arc<dyn ScanViewService>;
188
189#[derive(Debug, Default)]
190pub struct InMemoryScanViewService {
191 views: RwLock<HashMap<BufferId, Arc<RwLock<ScanViewState>>>>,
192}
193
194impl InMemoryScanViewService {
195 pub fn new() -> Self {
196 Self::default()
197 }
198
199 pub fn handle() -> ScanViewServiceHandle {
200 Arc::new(Self::new())
201 }
202}
203
204impl ScanViewService for InMemoryScanViewService {
205 fn state(&self, view: BufferId) -> Option<Arc<RwLock<ScanViewState>>> {
206 self.views.read().ok()?.get(&view).cloned()
207 }
208
209 fn set_state(&self, view: BufferId, state: ScanViewState) {
210 if let Ok(mut m) = self.views.write() {
211 m.insert(view, Arc::new(RwLock::new(state)));
212 }
213 }
214
215 fn clear(&self, view: BufferId) {
216 if let Ok(mut m) = self.views.write() {
217 m.remove(&view);
218 }
219 }
220}
221
222/// OA.27 — [`lattice_core::ViewArgsResolver`] over the per-view scan state.
223///
224/// The state this reads is the same `options.scan_args` the trigger carries
225/// forward on every re-open, which is what makes it the truth rather than a
226/// second copy of it: a view that re-scanned with different arguments has them
227/// here before its first row lands.
228///
229/// Wired at boot beside the excerpt-source resolver. A host with no
230/// `ScanViewService` cannot have a scan view open either, so `None` is not a
231/// degradation there — it is the accurate answer.
232#[derive(Debug)]
233pub struct ScanViewArgs {
234 service: ScanViewServiceHandle,
235}
236
237impl ScanViewArgs {
238 pub fn new(service: ScanViewServiceHandle) -> Self {
239 Self { service }
240 }
241}
242
243impl lattice_core::ViewArgsResolver for ScanViewArgs {
244 fn view_args(&self, buffer: BufferId) -> Option<Vec<String>> {
245 let state = self.service.state(buffer)?;
246 let read = state.read().ok()?;
247 Some(read.options.scan_args.clone())
248 }
249}
250
251// ─────────────────────────────────────────────────────────────────
252// The trigger
253// ─────────────────────────────────────────────────────────────────
254
255/// Open (or re-drive) the agenda view.
256///
257/// MV.3 — the identity of a scan-driven view: which provider name, which
258/// buffer, which minor to activate.
259///
260/// The agenda used to hold these as module constants, which is precisely what
261/// made the view un-ownable by the plugin whose feature it is. They are a
262/// parameter now, so the same machinery serves the agenda and any
263/// plugin-declared `scan` view; only the names differ.
264///
265/// The machinery itself does NOT move to the guest and is not meant to: the
266/// bounded walk, the batched reads, the read-and-parse-once handoff, the stable
267/// sort and the group-run computation are measured host work that no plugin
268/// should reimplement. What moves is who says what the view is called.
269#[derive(Debug, Clone)]
270pub struct ScanViewIdentity {
271 /// The provider name, used in messages and in the headerline prefix.
272 pub provider: String,
273 /// The view's buffer name.
274 pub buffer_name: String,
275 /// A minor the owner wants on the view, beyond the host's own refresh mode.
276 pub view_mode: Option<String>,
277 /// What to say when no source produces rows for this view.
278 ///
279 /// A field rather than a generic sentence because the message is
280 /// USER-VISIBLE behaviour, and MV.3 is a migration of ownership, not of
281 /// behaviour. Parameterising the wording along with the prefix silently
282 /// reworded the agenda's decline; `agenda_declines_when_no_plugin_provides_rows`
283 /// caught it, which is exactly the job the "existing tests pass unedited"
284 /// rule was given.
285 pub no_rows_message: String,
286}
287
288/// OA.11a: split a trigger's arguments into the host's slot and the guest's.
289///
290/// The arguments are **positional**: index 0 is the root override, which the
291/// host interprets because it does the walk, and everything after it is the
292/// source's own vocabulary, which the host must not read. `Args::String` is
293/// the one-argument spelling and still means a root, so every trigger that
294/// predates this slice — `:org-agenda`, `:org-agenda ~/notes` — arrives here
295/// unchanged.
296///
297/// Returns the root already trimmed; empty means "no override", which is how a
298/// caller names scan args without naming a root.
299fn split_view_args(args: &Args) -> (String, Vec<String>) {
300 let as_str = |v: &lattice_grammar::args::ArgValue| match v {
301 lattice_grammar::args::ArgValue::String(s) => s.clone(),
302 // The boundary admits only strings here, so this is unreachable from a
303 // plugin. A native caller passing something else gets the slot treated
304 // as absent rather than stringified into a path nobody typed.
305 _ => String::new(),
306 };
307 match args {
308 Args::String(s) => (s.trim().to_string(), Vec::new()),
309 Args::List(values) => {
310 let mut it = values.iter().map(as_str);
311 let root = it.next().unwrap_or_default().trim().to_string();
312 (root, it.collect())
313 }
314 _ => (String::new(), Vec::new()),
315 }
316}
317
318/// MV.3: the agenda's opener, with its identity as a parameter.
319///
320/// Byte-for-byte the previous `open_agenda` body except that the four names it
321/// used to hard-code now come from `identity`. Kept that way on purpose: the
322/// agenda is the first thing ever to run through the plugin-view seam (MV.2 was
323/// dropped), so the migration must be a rename of who-decides, not a rewrite of
324/// what-happens. Every existing agenda test passes unedited, which is the only
325/// signal separating "ownership moved" from "behaviour moved".
326pub fn open_scan_view(
327 activator: &mut dyn ModeActivator,
328 identity: &ScanViewIdentity,
329 args: &Args,
330) -> ProviderViewOutcome {
331 let name = identity.provider.as_str();
332 let services = activator.services();
333
334 let Some(sources) = services.get::<ScannedExcerptSourceRegistryHandle>() else {
335 return ProviderViewOutcome::Declined {
336 message: format!(
337 "{name}: no scanned-excerpt-source registry; the plugin host is not wired"
338 ),
339 };
340 };
341 let snapshot = sources.load();
342 if snapshot.is_empty() {
343 // Not an error — it is the honest state of an editor with no agenda
344 // plugin installed. Opening an empty view and leaving the user to
345 // guess why is the worse UX (the `Declined` contract).
346 return ProviderViewOutcome::Declined {
347 message: format!("{name}: {}", identity.no_rows_message),
348 };
349 }
350
351 let Some(registry) = services.get::<CommandRegistryHandle>() else {
352 return ProviderViewOutcome::Declined {
353 message: format!("{name}: command registry unavailable; cannot open the view"),
354 };
355 };
356 let lang_registry = services
357 .get::<Arc<lattice_syntax::LangRegistry>>()
358 .map(|h| (*h).clone());
359
360 // Read before any `&mut` use of the activator: `snapshot` borrows the
361 // registry's ArcSwap guard, and the activation calls below need the
362 // activator mutably.
363 let view_modes = snapshot.view_modes();
364
365 let existing = existing_view(&services, &identity.buffer_name);
366
367 // The root, in precedence order: an explicit argument, else the root the
368 // OPEN view already shows, else the active buffer's project.
369 //
370 // `:agenda ~/notes` from a code checkout scans the notes, which is the
371 // point of accepting an argument at all. The middle case is what makes
372 // OM.A3's `gr` a refresh rather than a reset: refreshing an agenda over
373 // `~/notes` must not silently turn it into an agenda over the current
374 // checkout, which is the mistake magit's `gr` documents at PD.9.
375 let mut options = ScanViewOptions::default();
376 if let Some(view) = existing
377 && let Some(svc) = services.get::<ScanViewServiceHandle>()
378 && let Some(state) = svc.state(view)
379 && let Ok(state) = state.read()
380 {
381 options = state.options.clone();
382 }
383 let (root_arg, scan_args) = split_view_args(args);
384 if !root_arg.is_empty() {
385 // An explicit argument REPLACES the list rather than joining it:
386 // `:org-agenda ~/notes` means "this, instead of what I configured",
387 // which is what makes the argument an escape hatch and not a filter.
388 options.roots = vec![PathBuf::from(shellexpand_tilde(&root_arg))];
389 }
390 if !scan_args.is_empty() {
391 options.scan_args = scan_args;
392 }
393
394 let view = match existing {
395 Some(view) => view,
396 None => create_multibuffer_view(
397 activator,
398 HashMap::new(),
399 Vec::new(),
400 Some(identity.buffer_name.clone()),
401 BufferFlags::default(),
402 (*registry).clone(),
403 lang_registry,
404 // AF.1: the agenda groups by DATE and SECTION, and its rows
405 // interleave across files on purpose (OM.A2) — so a file is the
406 // one thing that is not a contiguous run here. Folding by source
407 // file would make `home.org`'s fold swallow every `work.org` row
408 // sitting between its earliest and latest entry.
409 crate::FoldGrouping::HeaderRuns,
410 ),
411 };
412
413 let Some(mb_registry) = services.get::<MultibufferRegistryHandle>() else {
414 return ProviderViewOutcome::Declined {
415 message: format!("{name}: multibuffer registry unavailable; cannot open the view"),
416 };
417 };
418 let Some(handle) = mb_registry.handle(view) else {
419 return ProviderViewOutcome::Declined {
420 message: format!("{name}: the view failed to open"),
421 };
422 };
423
424 if let Some(svc) = services.get::<ScanViewServiceHandle>() {
425 svc.set_state(
426 view,
427 ScanViewState {
428 provider: identity.provider.clone(),
429 options: options.clone(),
430 // The scan that is about to run fills this; carrying the
431 // PREVIOUS scan's spans forward would be worse than empty,
432 // since a stale total reads exactly like a current one.
433 clock: Vec::new(),
434 // Same reasoning, and the same failure: an annotation left
435 // over from the previous scan hangs under whichever row now
436 // happens to occupy that composed index.
437 annotations: Vec::new(),
438 annotations_version: 0,
439 },
440 );
441 // HB.5: the rows a scan hangs below its own. Registered at OPEN rather
442 // than when the annotations arrive, because the provider reads the
443 // state through the SERVICE on every `collect` — so it is correct
444 // before the first scan (it emits nothing) and stays correct across
445 // refreshes.
446 //
447 // That indirection is deliberate. `set_state` inserts a NEW
448 // `Arc<RwLock<ScanViewState>>` on every open, so a provider that
449 // captured the Arc here would hold the first scan's state forever and
450 // freeze its rows at whatever the first `gr` found — visible only as
451 // "the graph stopped updating", which is a bad thing to debug.
452 let theme = services
453 .get::<lattice_theme::ThemeRegistryHandle>()
454 .map(|t| (*t).clone());
455 let provider: Arc<dyn lattice_cells::VirtualRowProvider> =
456 Arc::new(RowAnnotationProvider::new(view, (*svc).clone(), theme));
457 // `register_virtual_row_provider` dedups by id, so a refresh that
458 // re-opens the same view re-registers the same provider rather than
459 // stacking a second one under every row.
460 activator.register_virtual_row_provider(view, provider);
461 } else {
462 tracing::debug!("scan-view: service not registered; the view's root will not be tracked");
463 }
464
465 // OA.0c: the view is NOT emptied here. The scan collects every file,
466 // sorts once and writes its rows in a single terminal call, so clearing
467 // up front left the view blank for the WHOLE scan — which is why a slow
468 // scan read as "refresh is broken" rather than as "refresh is slow". A
469 // refresh now shows the previous rows, marked in-progress, until the new
470 // ones replace them atomically (`append_sorted`) or a genuinely empty
471 // result clears them (`finish_empty`).
472 //
473 // A first open has nothing to keep, so this costs it nothing.
474 handle.set_headerline(HeaderlineStatus::InProgress {
475 label: "Building agenda".to_string(),
476 count: Some(0),
477 emphasis: None,
478 });
479
480 // The root this view scanned, so `:files` / `:search` from inside the
481 // agenda answer for the project the rows came from rather than for the
482 // process working directory. The view has no path of its own.
483 activator.set_buffer_scope_dir(view, options.scope_dir());
484
485 // OM.A3: the view's own minor. `gr` arrives through the implies cascade
486 // (`refresh_action` returns `Some`), so this one call is the whole of the
487 // wiring — the same shape `project_search` uses for `ProjectSearchMode`.
488 activator.activate_minor_by_id(view, ScanViewMode::mode_id());
489
490 // MV.3: the view OWNER's minor, when it declared one. Distinct from the
491 // per-source minors below: this one belongs to whoever owns the view, those
492 // belong to whoever produced its rows. The agenda declares none — its
493 // interactions come through its sources' `view-mode`, which is how
494 // `org-agenda-mode` reaches it today and continues to.
495 if let Some(mode) = identity.view_mode.as_deref() {
496 activator.activate_minor_by_id(view, lattice_mode::ModeId::new(mode));
497 }
498
499 // …and each SOURCE's own minor, so a producer can act on its own rows.
500 // The host stays generic: it activates a mode by the name the source
501 // declared and never learns what the chords do. A name that is not
502 // registered echoes a warning through the ordinary activation path rather
503 // than failing the open — the rows are still worth showing.
504 for mode in view_modes {
505 activator.activate_minor_by_id(view, lattice_mode::ModeId::new(&mode));
506 }
507
508 let events = services.get::<Arc<EventBus>>().map(|b| (*b).clone());
509 let sources_for_scan = snapshot.sources();
510 drop(snapshot);
511 spawn_scan_view_scan(
512 view,
513 options,
514 sources_for_scan,
515 (*mb_registry).clone(),
516 events,
517 Some(Arc::clone(&services)),
518 );
519
520 ProviderViewOutcome::Opened {
521 view,
522 message: Some(format!("{name}: scanning…")),
523 }
524}
525
526/// The already-open agenda view, if there is one.
527fn existing_view(services: &ServiceRegistry, view_name: &str) -> Option<BufferId> {
528 let buffers = services.get::<lattice_mode::BufferStoreHandle>()?;
529 let id = buffers.find_by_name(view_name)?;
530 let registry = services.get::<MultibufferRegistryHandle>()?;
531 registry.handle(id).map(|_| id)
532}
533
534/// `~` expansion for a hand-typed root.
535///
536/// Delegates to [`lattice_core::home::expand_tilde`]. It used to resolve the
537/// home directory as `std::env::var_os("HOME")`, which is POSIX-only — so on
538/// Windows `~/notes` stayed verbatim, found nothing, and the agenda reported an
539/// empty corpus while this option's own documentation promised "`~` is
540/// expanded".
541fn shellexpand_tilde(raw: &str) -> String {
542 lattice_core::home::expand_tilde(raw)
543}
544
545// ─────────────────────────────────────────────────────────────────
546// The scan
547// ─────────────────────────────────────────────────────────────────
548
549/// One file's contribution: its text (kept so the source `Document` is built
550/// from the SAME bytes the guest saw) and the rows found in it.
551struct FileRows {
552 path: PathBuf,
553 text: String,
554 entries: Vec<ScannedExcerpt>,
555}
556
557/// A row after the cross-file sort, carrying the index of its file.
558struct SortedRow {
559 file: usize,
560 entry: ScannedExcerpt,
561}
562
563/// Run the scan off-thread and populate `view`.
564///
565/// Shape, and why:
566///
567/// - The `ignore::Walk` and every `read_to_string` run under
568/// **`spawn_blocking`**. The editor actor is a `current_thread` runtime, so
569/// a bare `tokio::spawn` would land the whole walk on the actor thread —
570/// paramount goal #1's forbidden pattern.
571/// - The guest `scan` calls are `await`ed on the async runtime between those
572/// hops, because a wasmtime async call must not be made from a blocking
573/// task.
574/// - **A file no source claims is never read.** The extension test happens
575/// before the read, so an agenda over a Rust checkout costs a directory
576/// walk and nothing else.
577/// - The append publishes [`MultibufferExcerptsReady`] — the registered
578/// off-keystroke wake. Without it the rows would sit invisible until the
579/// user pressed a key, which reads as a rendering fault and is not one.
580///
581/// `pub` for the same reason `search::spawn_scan_task` is: OM.A3's `gr`
582/// handler respawns without going back through the trigger, and the
583/// throughput bench drives it without an activator.
584#[allow(clippy::too_many_arguments)]
585pub fn spawn_scan_view_scan(
586 view: BufferId,
587 options: ScanViewOptions,
588 sources: Vec<Arc<dyn ScannedExcerptSource>>,
589 mb_registry: MultibufferRegistryHandle,
590 events: Option<Arc<EventBus>>,
591 // OA.5: `services` because the scan carries per-row style spans back, and
592 // publishing them needs two things the walk itself does not — the
593 // synthetic-highlight sink and the theme that resolves a slot name. A
594 // registry rather than two handles: the scan runs long after the opener
595 // returned, and a third consumer would otherwise mean a third parameter.
596 services: Option<Arc<lattice_mode::ServiceRegistry>>,
597) {
598 tokio::spawn(async move {
599 // `begin` first, and a source that refuses is dropped from THIS scan
600 // rather than failing it: its per-scan state is now unknown, so its
601 // rows would be untrustworthy, but the other sources' are fine.
602 let mut live: Vec<Arc<dyn ScannedExcerptSource>> = Vec::with_capacity(sources.len());
603 for source in sources {
604 match source.begin(&options.scan_args).await {
605 Ok(()) => live.push(source),
606 Err(e) => tracing::debug!(
607 source = source.source_id(),
608 error = %e,
609 "scan-view: a source failed to begin; skipping it this scan"
610 ),
611 }
612 }
613 if live.is_empty() {
614 finish_empty(&mb_registry, &events, view, &ScanOutcome::default());
615 return;
616 }
617
618 // OA.22: ask the live sources what this view IS, once, before the walk.
619 //
620 // Before the walk rather than after, so the in-progress header carries
621 // it too — a long scan is exactly when the user has time to wonder what
622 // they are looking at. One crossing per scan, not per file.
623 //
624 // Joined with `·` when more than one source answers, which is the
625 // honest rendering: the view really is the union of what they found,
626 // and picking one arbitrarily would name a filter that governs only
627 // part of the rows.
628 let label = {
629 let mut parts: Vec<String> = Vec::new();
630 for source in &live {
631 let said = source.describe(&options.scan_args).await;
632 if !said.trim().is_empty() {
633 parts.push(said.trim().to_string());
634 }
635 }
636 parts.join(" · ")
637 };
638
639 // AF.2: resolve the roots, most specific first.
640 //
641 // 1. what the caller named (an explicit argument, or the open view's
642 // own stored root on `gr`);
643 // 2. what the live sources ask for — their users' configuration;
644 // 3. the project root, which is what an editor with no agenda
645 // configuration has always scanned.
646 //
647 // Asked here rather than at open because a source's answer comes from
648 // the guest, and the opener runs on the dispatch path where a guest
649 // call does not belong.
650 let mut roots = options.roots.clone();
651 if roots.is_empty() {
652 for source in &live {
653 match source.roots().await {
654 Ok(named) => roots.extend(
655 named
656 .iter()
657 .map(|r| PathBuf::from(shellexpand_tilde(r.trim())))
658 .filter(|p| !p.as_os_str().is_empty()),
659 ),
660 // A source that cannot say where to look must not be able
661 // to make the agenda scan nothing.
662 Err(e) => tracing::debug!(
663 source = source.source_id(),
664 error = %e,
665 "scan-view: a source could not name its roots; ignoring it"
666 ),
667 }
668 }
669 roots.sort();
670 roots.dedup();
671 }
672 if roots.is_empty() {
673 roots.push(project_root_from_cwd());
674 }
675
676 let max_files = options.max_files.unwrap_or(usize::MAX);
677 let extensions: Vec<String> = live
678 .iter()
679 .flat_map(|s| s.extensions().iter().cloned())
680 .collect();
681
682 let walk_roots = roots.clone();
683 let candidates = match tokio::task::spawn_blocking(move || {
684 collect_candidates(&walk_roots, &extensions, max_files)
685 })
686 .await
687 {
688 Ok(paths) => paths,
689 Err(e) => {
690 tracing::warn!(error = %e, "scan-view: the walk task failed");
691 Vec::new()
692 }
693 };
694
695 let total = candidates.len();
696 let mut files: Vec<FileRows> = Vec::new();
697 // OA.14b: every clocked span the walk saw, across every file and every
698 // source. Accumulated flat rather than per file because the report
699 // groups by outline and day, not by which file a span came from — and
700 // a headline's time is its own wherever it lives.
701 let mut clock: Vec<lattice_mode::ClockSpan> = Vec::new();
702 let mut scanned = 0usize;
703 // OM.A3: a source that stops answering must not cost the user the rows
704 // already collected, and must not be silently absent either.
705 // `Health` counts a source's consecutive failures; `dropped` is the
706 // set that ran out of them, and it is what makes the terminal
707 // headerline say "partial" instead of implying a complete agenda.
708 let mut health: HashMap<u64, u32> = HashMap::new();
709 let mut dropped: Vec<u64> = Vec::new();
710 let mut skipped_files = 0usize;
711
712 for chunk in candidates.chunks(READ_BATCH) {
713 // The view may have been closed while the scan ran.
714 if mb_registry.handle(view).is_none() {
715 return;
716 }
717 let owned: Vec<PathBuf> = chunk.to_vec();
718 let read = match tokio::task::spawn_blocking(move || read_batch(&owned)).await {
719 Ok(read) => read,
720 Err(e) => {
721 tracing::warn!(error = %e, "scan-view: a read batch failed; skipping it");
722 continue;
723 }
724 };
725
726 for (path, text) in read {
727 scanned += 1;
728 for source in &live {
729 let id = source.source_id();
730 if dropped.contains(&id) || !source.claims(&path) {
731 continue;
732 }
733 match source.scan(path.clone(), text.clone()).await {
734 Ok(result) => {
735 // A file it could read resets the counter: the
736 // budget is for a source that has STOPPED
737 // answering, not one with a few bad files in a
738 // large project.
739 health.insert(id, 0);
740 // OA.14b: clock spans are collected whether or not
741 // the file produced any ROWS. A file whose only
742 // headline is untagged and undated contributes no
743 // agenda row and can still hold a week of clocked
744 // time — dropping it with the rows is exactly the
745 // under-reporting this seam exists to avoid.
746 if !result.clock.is_empty() {
747 clock.extend(result.clock);
748 }
749 if !result.entries.is_empty() {
750 files.push(FileRows {
751 path: path.clone(),
752 text: text.clone(),
753 entries: result.entries,
754 });
755 }
756 }
757 // One malformed file must not fail the agenda —
758 // `error-parser`'s rule, same failure class. `debug!`
759 // because a project-wide scan would flood `info!`.
760 Err(e) => {
761 skipped_files += 1;
762 let strikes = health.entry(id).or_insert(0);
763 *strikes += 1;
764 tracing::debug!(
765 path = %path.display(),
766 error = %e,
767 strikes = *strikes,
768 "scan-view: a source could not scan a file; skipping it"
769 );
770 // A quarantined plugin errors on EVERY later call,
771 // so continuing to ask costs a channel round-trip
772 // per remaining file to learn nothing. Drop it and
773 // keep walking for the other sources.
774 if *strikes >= SOURCE_FAILURE_BUDGET {
775 tracing::warn!(
776 source = id,
777 "scan-view: a source failed {SOURCE_FAILURE_BUDGET} files in a \
778 row; dropping it from this scan (the agenda will be partial)"
779 );
780 dropped.push(id);
781 }
782 }
783 }
784 }
785 }
786
787 if scanned % PROGRESS_INTERVAL < READ_BATCH
788 && let Some(handle) = mb_registry.handle(view)
789 {
790 handle.set_headerline(HeaderlineStatus::InProgress {
791 label: if label.is_empty() {
792 format!("Building agenda ({scanned}/{total} files)")
793 } else {
794 format!("Building agenda: {label} ({scanned}/{total} files)")
795 },
796 count: Some(files.iter().map(|f| f.entries.len()).sum()),
797 emphasis: (!label.is_empty()).then(|| label.clone()),
798 });
799 }
800 }
801
802 let outcome = ScanOutcome {
803 files_scanned: scanned,
804 skipped_files,
805 dropped_sources: dropped.len(),
806 label: label.clone(),
807 };
808 // OA.14b: publish the clock spans the walk collected, at the END and in
809 // one write. A report is a total; a half-filled one is simply wrong,
810 // and unlike rows — which stream so the view fills in — nothing reads
811 // this until the scan has finished.
812 //
813 // Published even when the scan produced no ROWS: a corpus can hold a
814 // week of clocked time and not a single agenda row, and that is
815 // precisely the case a report has to answer.
816 if let Some(services) = services.as_deref()
817 && let Some(svc) = services.get::<ScanViewServiceHandle>()
818 && let Some(state) = svc.state(view)
819 && let Ok(mut state) = state.write()
820 {
821 state.clock = clock;
822 }
823 let sorted = sort_rows(&files);
824 if sorted.is_empty() {
825 finish_empty(&mb_registry, &events, view, &outcome);
826 } else {
827 append_sorted(
828 &mb_registry,
829 &events,
830 view,
831 &files,
832 sorted,
833 &outcome,
834 services.as_deref(),
835 );
836 }
837 });
838}
839
840/// How many consecutive files a source may fail before the scan stops asking.
841///
842/// Small on purpose. The failure this defends against is a QUARANTINED plugin,
843/// which errors on every call forever — three strikes distinguishes it from a
844/// handful of malformed files without making a large project pay a channel
845/// round-trip per file to keep confirming the same answer.
846const SOURCE_FAILURE_BUDGET: u32 = 3;
847
848/// What a finished scan has to be honest about.
849///
850/// Partial-and-honest beats empty-and-silent (`org-mode.md` §8) — but it also
851/// beats *partial-and-silent*, which is what a bare row count would be: an
852/// agenda missing a source's rows looks exactly like an agenda that had none.
853// Not `Copy` since OA.22 — the label is a `String`. Cheap to clone at the one
854// call site that needs it (once per scan).
855#[derive(Debug, Default, Clone)]
856struct ScanOutcome {
857 files_scanned: usize,
858 skipped_files: usize,
859 dropped_sources: usize,
860 /// OA.22: what the SOURCE says this view is — its command, span and active
861 /// filters. Empty when no source had anything to say, which keeps the
862 /// header in its plain form.
863 label: String,
864}
865
866/// OA.22: `"[agenda] "` or `"[agenda: Waiting · +work] "`.
867///
868/// The label goes in the BRACKET rather than after the counts, because the
869/// bracket is what the eye reads as "which view is this" — and the question a
870/// filtered agenda has to answer is exactly that. A filtered agenda that looks
871/// unfiltered is the trap: "you have no tasks" is the worst thing this view can
872/// say incorrectly, and a forgotten filter is the likeliest way to make it say
873/// so.
874fn header_prefix(provider: &str, label: &str) -> String {
875 if label.is_empty() {
876 format!("[{provider}] ")
877 } else {
878 format!("[{provider}: {label}] ")
879 }
880}
881
882impl ScanOutcome {
883 /// The `— partial: …` suffix, or empty when the scan was clean.
884 fn caveat(&self) -> String {
885 if self.dropped_sources > 0 {
886 format!(
887 " — partial: {} source(s) stopped responding",
888 self.dropped_sources
889 )
890 } else if self.skipped_files > 0 {
891 format!(" ({} file(s) skipped)", self.skipped_files)
892 } else {
893 String::new()
894 }
895 }
896}
897
898/// Collect the paths at least one source claims. Blocking by construction.
899///
900/// `ignore::Walk` respects `.gitignore` / `.ignore`, which is what stops an
901/// agenda over a checkout from scanning `target/`.
902/// AF.2: every candidate across `roots`, capped at `max_files` in TOTAL.
903///
904/// A root that is a FILE is taken as given without the extension test — naming
905/// a file is the claim, and a user who writes `~/notes/birthdays.txt` in their
906/// agenda files meant it. A root that is a directory is walked as before.
907///
908/// A root that does not exist is skipped at `info!`: user-actionable (it is
909/// their configuration), and one bad entry must not fail the agenda, which is
910/// the same rule the per-file reads already follow.
911///
912/// De-duplicated, because a configured file inside a configured directory is an
913/// ordinary way to write "everything here, and that one too" and must not scan
914/// twice.
915fn collect_candidates(roots: &[PathBuf], extensions: &[String], max_files: usize) -> Vec<PathBuf> {
916 let mut out: Vec<PathBuf> = Vec::new();
917 let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
918 for root in roots {
919 if out.len() >= max_files {
920 break;
921 }
922 let meta = match std::fs::metadata(root) {
923 Ok(meta) => meta,
924 Err(error) => {
925 tracing::info!(
926 path = %root.display(),
927 %error,
928 "scan-view: a configured path could not be read; skipping it"
929 );
930 continue;
931 }
932 };
933 let found = if meta.is_file() {
934 vec![root.clone()]
935 } else {
936 walk_candidates(root, extensions, max_files - out.len())
937 };
938 for path in found {
939 let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
940 if seen.insert(key) {
941 out.push(path);
942 }
943 if out.len() >= max_files {
944 break;
945 }
946 }
947 }
948 // No silent truncation: a short agenda that looks complete is worse than a
949 // slow one, so the cap says what it dropped.
950 if out.len() >= max_files {
951 tracing::info!(
952 max_files,
953 "scan-view: hit the file cap; the view is partial"
954 );
955 }
956 out
957}
958
959/// The files a configured directory names — **one level, not the subtree**.
960///
961/// OA.0d. This walked recursively, which no configuration asked for: emacs
962/// expands a directory in `org-agenda-files` with `directory-files`, one
963/// level, and a user who wants a subdirectory lists it. An org directory with
964/// `roam/`, `journal/` or `archive/` under it was pulling all of them into
965/// every scan — wrong rows, and a corpus far larger than the one configured.
966///
967/// No new option to express this. The roots list is already the mechanism:
968/// naming a subdirectory opts it in, which is exactly how emacs users do it
969/// and why emacs never needed a recursion flag either.
970///
971/// `max_depth(1)` rather than `read_dir` so `ignore`'s hidden-file and
972/// `.gitignore` filtering still applies — `.git` and ignored files stay out,
973/// which is the one thing the recursive walk was doing right.
974///
975/// Sorted by file name, because the order is observable twice over: it decides
976/// WHICH files a `max_files` cap keeps, and it decides whether a source's bad
977/// files arrive consecutively and spend its failure budget. Directory order is
978/// whatever the filesystem returns, so unsorted, the same directory gave a
979/// different agenda on a different machine.
980fn walk_candidates(root: &Path, extensions: &[String], max_files: usize) -> Vec<PathBuf> {
981 let mut out = Vec::new();
982 for entry in ignore::WalkBuilder::new(root)
983 .max_depth(Some(1))
984 .sort_by_file_name(|a, b| a.cmp(b))
985 .build()
986 {
987 if out.len() >= max_files {
988 break;
989 }
990 let Ok(entry) = entry else { continue };
991 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
992 continue;
993 }
994 let path = entry.into_path();
995 if claims_any(&path, extensions) {
996 out.push(path);
997 }
998 }
999 out
1000}
1001
1002/// Does any registered extension match `path`? The union test, so the walk
1003/// reads a file once even when two sources claim it.
1004fn claims_any(path: &Path, extensions: &[String]) -> bool {
1005 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
1006 return false;
1007 };
1008 let lowered = ext.to_ascii_lowercase();
1009 extensions.iter().any(|e| *e == lowered)
1010}
1011
1012/// Read a batch of files, dropping the ones that cannot be read.
1013///
1014/// A file that vanished between the walk and the read, or one that is not
1015/// UTF-8, is skipped rather than failing the batch.
1016fn read_batch(paths: &[PathBuf]) -> Vec<(PathBuf, String)> {
1017 paths
1018 .iter()
1019 .filter_map(|p| match std::fs::read_to_string(p) {
1020 Ok(text) => Some((p.clone(), text)),
1021 Err(e) => {
1022 tracing::debug!(path = %p.display(), error = %e, "scan-view: could not read a file");
1023 None
1024 }
1025 })
1026 .collect()
1027}
1028
1029/// Stable-sort every file's rows together on `sort_key`.
1030///
1031/// **Stable** matters: rows with the same key keep walk order, so two
1032/// same-day headlines in one file stay in the order they appear in the file
1033/// rather than shuffling between scans.
1034fn sort_rows(files: &[FileRows]) -> Vec<SortedRow> {
1035 let mut rows: Vec<SortedRow> = files
1036 .iter()
1037 .enumerate()
1038 .flat_map(|(i, f)| {
1039 f.entries
1040 .iter()
1041 .cloned()
1042 .map(move |entry| SortedRow { file: i, entry })
1043 })
1044 .collect();
1045 rows.sort_by_key(|r| r.entry.sort_key);
1046 rows
1047}
1048
1049/// Build one source `Document` per contributing file and append the rows in
1050/// sorted order, titling the first row of each group run.
1051#[allow(clippy::too_many_arguments)]
1052fn append_sorted(
1053 mb_registry: &MultibufferRegistryHandle,
1054 events: &Option<Arc<EventBus>>,
1055 view: BufferId,
1056 files: &[FileRows],
1057 rows: Vec<SortedRow>,
1058 outcome: &ScanOutcome,
1059 services: Option<&lattice_mode::ServiceRegistry>,
1060) {
1061 let Some(handle) = mb_registry.handle(view) else {
1062 return;
1063 };
1064
1065 // One source document per file, however many rows it contributed —
1066 // otherwise a file with five agenda entries would be opened five times
1067 // and an edit through one row would not be visible through the others.
1068 let mut source_ids: HashMap<usize, BufferId> = HashMap::new();
1069 let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
1070 for row in &rows {
1071 source_ids.entry(row.file).or_insert_with(|| {
1072 let f = &files[row.file];
1073 let id = BufferId::next();
1074 let document = DocumentBuilder::default()
1075 .with_text(&f.text)
1076 .with_path(f.path.clone())
1077 .build();
1078 let registry = Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()));
1079 let doc_handle = spawn_document(id, document, registry);
1080 let doc = Arc::new(doc_handle) as Arc<dyn Document>;
1081 // `add_source` is not redundant with the map below. It is what
1082 // derives the per-excerpt `SyntaxHandle` from the path, and
1083 // `replace_excerpts` only swaps the source map — so building the
1084 // map alone leaves every agenda row uncoloured. Caught by
1085 // `agenda_rows_carry_per_excerpt_syntax_handles`, which is the
1086 // test AH.1 left behind for exactly this.
1087 handle.add_source(id, Arc::clone(&doc));
1088 sources.insert(id, doc);
1089 id
1090 });
1091 }
1092
1093 let excerpts = build_excerpts(&rows, &source_ids);
1094 let count = excerpts.len();
1095 publish_row_spans(services, view, &rows, &excerpts);
1096 publish_row_annotations(services, view, &rows, &excerpts);
1097 // OA.0c: REPLACE, not append. This is the swap that makes keeping the old
1098 // rows safe — appending onto a view we deliberately did not clear would
1099 // show the previous scan's rows and this one's together. Replacing also
1100 // drops the previous scan's source documents, which an append would leak
1101 // into the view for as long as it stayed open.
1102 //
1103 // (`state.source_syntax` is NOT re-baselined by `replace_excerpts`, so a
1104 // dropped source's handle outlives it. Pre-existing — the old
1105 // clear-then-append path had the same hole — and untouched here.)
1106 handle.replace_excerpts(sources, excerpts);
1107
1108 handle.set_headerline(HeaderlineStatus::Complete {
1109 summary: format!(
1110 "{}{count} row(s) in {} file(s){}",
1111 header_prefix("agenda", &outcome.label),
1112 outcome.files_scanned,
1113 outcome.caveat()
1114 ),
1115 // OA.22: the label carries the accent role
1116 // (`multibuffer.status.query`), the project-search precedent. What the
1117 // view IS — its window and its filters — is the part the eye should
1118 // land on; the counts beside it are the part that changes every scan
1119 // and means least. `None` when there is no label, which degrades to
1120 // the plain complete colour rather than accenting an empty string.
1121 emphasis: (!outcome.label.is_empty()).then(|| outcome.label.clone()),
1122 });
1123 if let Some(events) = events {
1124 events.publish_typed(MultibufferExcerptsReady { view });
1125 }
1126}
1127
1128/// Turn sorted rows into excerpts, giving the FIRST row of each group run its
1129/// `label` as a header and the rest an empty one.
1130///
1131/// An empty `ExcerptHeader.title` renders no header row, which is what makes
1132/// a date group show one header for N rows drawn from N different files
1133/// (§6.1). `path` is left `None` deliberately: the header renderer falls back
1134/// to the path when it has one, and an agenda groups by date, not by file.
1135fn build_excerpts(rows: &[SortedRow], source_ids: &HashMap<usize, BufferId>) -> Vec<Excerpt> {
1136 let mut out = Vec::with_capacity(rows.len());
1137 let mut current_group: Option<&str> = None;
1138 for row in rows {
1139 let Some(&source) = source_ids.get(&row.file) else {
1140 continue;
1141 };
1142 let starts_group = current_group != Some(row.entry.group.as_str());
1143 if starts_group {
1144 current_group = Some(row.entry.group.as_str());
1145 }
1146 let title = if starts_group {
1147 row.entry.label.clone()
1148 } else {
1149 String::new()
1150 };
1151 // MH.A6: read from the row that STARTS the group, like the label —
1152 // the producer sets it on every row of the group because it cannot
1153 // know which one the sort puts first.
1154 let mut header = ExcerptHeader::new(title);
1155 if starts_group && row.entry.emphasis {
1156 header.style = ExcerptHeaderStyle::Emphasis;
1157 }
1158 out.push(Excerpt::new(source, row.entry.line, row.entry.end_line).with_header(header));
1159 }
1160 out
1161}
1162
1163/// OA.5: paint the rows with the colour the source asked for.
1164///
1165/// Without this an agenda row is coloured by the SOURCE FILE's tree-sitter
1166/// grammar — all the host has — so the view looks like org text that happens
1167/// to be out of order. Which word is a TODO keyword, a priority or a tag is
1168/// org semantics, not the org grammar's.
1169///
1170/// Two translations happen here, and both are the reason this is host-side.
1171/// A producer reports offsets into its row's OWN line, because it cannot know
1172/// where that row lands until every other file's rows have been interleaved
1173/// by the sort; the composed row index is only knowable after
1174/// `build_excerpts`. And a `slot` is a NAME, resolved through exactly the path
1175/// a `highlights.scm` capture takes — so a plugin's own registered element
1176/// (`org.todo.WAITING`) picks up the active colourscheme, and an unresolvable
1177/// name renders unstyled rather than failing the row.
1178///
1179/// Silent no-op when no source asked for colour, which is the ordinary case:
1180/// an empty publish would still cost a drain and a repaint.
1181fn publish_row_spans(
1182 services: Option<&lattice_mode::ServiceRegistry>,
1183 view: BufferId,
1184 rows: &[SortedRow],
1185 excerpts: &[Excerpt],
1186) {
1187 let Some(services) = services else {
1188 return;
1189 };
1190 if rows.iter().all(|r| r.entry.spans.is_empty()) {
1191 return;
1192 }
1193 let Some(sink) = services.get::<lattice_mode::PendingSyntheticHighlights>() else {
1194 tracing::debug!("scan-view: no synthetic-highlight sink; rows keep grammar colour");
1195 return;
1196 };
1197 let theme = services.get::<lattice_theme::ThemeRegistryHandle>();
1198 let theme_ref: Option<&dyn lattice_theme::ThemeRegistry> = theme
1199 .as_deref()
1200 .map(|t| &**t as &dyn lattice_theme::ThemeRegistry);
1201
1202 sink.store_and_wake(view, composed_row_spans(rows, excerpts, theme_ref));
1203}
1204
1205/// The translation itself, pure so it can be tested without an Editor — the
1206/// sink's map is private and only the Editor drains it.
1207///
1208/// Two things to get right, and both fail INVISIBLY: the rows still render,
1209/// just wrongly coloured.
1210///
1211/// - A span's offsets stay relative to its own LINE and are not rebased.
1212/// - A row lands at its COMPOSED index, which is not its index among its own
1213/// file's rows — the sort interleaves files, so the two differ whenever
1214/// more than one file contributes.
1215fn composed_row_spans(
1216 rows: &[SortedRow],
1217 excerpts: &[Excerpt],
1218 theme: Option<&dyn lattice_theme::ThemeRegistry>,
1219) -> Vec<Vec<lattice_cells::StyledSpan>> {
1220 // `excerpt_start_rows` is the same helper the fold providers use, so a
1221 // row's spans and its fold agree on where that row is.
1222 let starts = crate::motions::excerpt_start_rows(excerpts);
1223 let total: usize = excerpts.iter().map(|e| e.line_count() as usize).sum();
1224 let mut out: Vec<Vec<lattice_cells::StyledSpan>> = vec![Vec::new(); total];
1225 for (row, &start) in rows.iter().zip(starts.iter()) {
1226 let Some(slot) = out.get_mut(start as usize) else {
1227 continue;
1228 };
1229 for span in &row.entry.spans {
1230 slot.push(lattice_cells::StyledSpan {
1231 start: span.start as usize,
1232 end: span.end as usize,
1233 style: lattice_syntax::style::name_to_style_with_theme(&span.slot, theme),
1234 });
1235 }
1236 }
1237 out
1238}
1239
1240/// HB.5: publish the rows a scan asked to hang below its own.
1241///
1242/// `publish_row_spans`' sibling, and it shares the translation deliberately:
1243/// `excerpt_start_rows` is the same helper the fold providers and the spans use,
1244/// so a row's annotation, its colour and its fold all agree on where that row
1245/// is. Three answers derived three ways would drift, and the drift would be
1246/// invisible — everything still renders, just attached to the wrong line.
1247fn publish_row_annotations(
1248 services: Option<&lattice_mode::ServiceRegistry>,
1249 view: BufferId,
1250 rows: &[SortedRow],
1251 excerpts: &[Excerpt],
1252) {
1253 let Some(services) = services else {
1254 return;
1255 };
1256 let Some(svc) = services.get::<ScanViewServiceHandle>() else {
1257 return;
1258 };
1259 let Some(state) = svc.state(view) else {
1260 return;
1261 };
1262 let Ok(mut state) = state.write() else {
1263 return;
1264 };
1265 // Written unconditionally, including when the answer is empty. A scan whose
1266 // rows lost their annotations must CLEAR the previous ones — the same rule
1267 // `finish_empty` follows for rows, and for the same reason: stale
1268 // decorations look exactly like current ones.
1269 state.annotations = composed_row_annotations(rows, excerpts);
1270 state.annotations_version = state.annotations_version.wrapping_add(1);
1271}
1272
1273/// The translation itself, pure so it can be tested without an editor.
1274///
1275/// A row's composed index is NOT its index among its own file's rows: the sort
1276/// interleaves files, so the two differ the moment more than one file
1277/// contributes. That is the mistake this function exists to make once, in the
1278/// same place `composed_row_spans` makes it.
1279fn composed_row_annotations(rows: &[SortedRow], excerpts: &[Excerpt]) -> Vec<RowAnnotationAt> {
1280 let starts = crate::motions::excerpt_start_rows(excerpts);
1281 rows.iter()
1282 .zip(starts.iter())
1283 .filter_map(|(row, &start)| {
1284 row.entry.annotation.as_ref().map(|a| RowAnnotationAt {
1285 row: start,
1286 annotation: a.clone(),
1287 })
1288 })
1289 .collect()
1290}
1291
1292/// The `ProviderId` a view's annotations register under.
1293///
1294/// A distinct salt from the excerpt-header, status and clock-report providers,
1295/// so the four never collide on one view.
1296pub fn row_annotation_provider_id(view: BufferId) -> lattice_cells::ProviderId {
1297 u64::from(view.0).wrapping_mul(4).wrapping_add(2)
1298}
1299
1300/// HB.5: renders a scan's per-row annotations as virtual rows below their rows.
1301///
1302/// Reads the view's state through the SERVICE on every call rather than holding
1303/// an `Arc` to it, because `set_state` replaces that `Arc` on every open — a
1304/// provider that captured one would freeze at the first scan's answer and its
1305/// symptom would be "the graph stopped updating".
1306pub struct RowAnnotationProvider {
1307 view: BufferId,
1308 service: ScanViewServiceHandle,
1309 /// `None` on test paths that wire no theme; cells then carry the `0`
1310 /// "renderer's default" sentinel, exactly as the clock report's do.
1311 theme: Option<lattice_theme::ThemeRegistryHandle>,
1312}
1313
1314impl std::fmt::Debug for RowAnnotationProvider {
1315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1316 f.debug_struct("RowAnnotationProvider")
1317 .field("view", &self.view)
1318 .finish()
1319 }
1320}
1321
1322impl RowAnnotationProvider {
1323 pub fn new(
1324 view: BufferId,
1325 service: ScanViewServiceHandle,
1326 theme: Option<lattice_theme::ThemeRegistryHandle>,
1327 ) -> Self {
1328 Self {
1329 view,
1330 service,
1331 theme,
1332 }
1333 }
1334
1335 fn annotations(&self) -> Vec<RowAnnotationAt> {
1336 self.service
1337 .state(self.view)
1338 .and_then(|s| s.read().ok().map(|s| s.annotations.clone()))
1339 .unwrap_or_default()
1340 }
1341}
1342
1343impl lattice_cells::VirtualRowProvider for RowAnnotationProvider {
1344 fn id(&self) -> lattice_cells::ProviderId {
1345 row_annotation_provider_id(self.view)
1346 }
1347
1348 /// The state's counter, plus the theme's — a `:colorscheme` swap must
1349 /// repaint the rows rather than leave them in the previous palette, which
1350 /// is the lesson the clock report's `version` already carries.
1351 fn version(&self) -> u64 {
1352 let rows = self
1353 .service
1354 .state(self.view)
1355 .and_then(|s| s.read().ok().map(|s| s.annotations_version))
1356 .unwrap_or(0);
1357 let theme = self
1358 .theme
1359 .as_ref()
1360 .map(|t| t.resolved().version())
1361 .unwrap_or(0);
1362 rows.wrapping_mul(31).wrapping_add(theme)
1363 }
1364
1365 fn collect(&self) -> Vec<lattice_cells::VirtualRow> {
1366 // Resolved ONCE per collect, off the UI thread, and baked into the
1367 // cells — the established pattern. `0` is the Cell "use the renderer's
1368 // default" sentinel, which an unthemed harness and an unresolvable
1369 // element name both land on.
1370 let resolved = self.theme.as_ref().map(|t| t.resolved());
1371 // A slot on an annotation names a THEME ELEMENT, and only that.
1372 //
1373 // `entry.spans` resolves through `name_to_style_with_theme`, which also
1374 // recognises tree-sitter capture names — but those stay a semantic
1375 // `Style` that the cells worker colours at paint time, and a virtual
1376 // row's cells carry a baked `u32`. So the two seams accept the same
1377 // shape and not quite the same vocabulary, which is stated in the WIT
1378 // rather than left for someone to find by getting a black row.
1379 let fg_of = |slot: &str| -> u32 {
1380 let (Some(theme), Some(resolved)) = (self.theme.as_ref(), resolved.as_ref()) else {
1381 return 0;
1382 };
1383 theme
1384 .id(&lattice_theme::ElementName::from(slot.to_string()))
1385 .and_then(|id| resolved.get(id).fg)
1386 .map(|c| c.to_rgb_u32(0))
1387 .unwrap_or(0)
1388 };
1389 self.annotations()
1390 .into_iter()
1391 .map(|a| {
1392 let styled: Vec<(usize, usize, u32)> = a
1393 .annotation
1394 .spans
1395 .iter()
1396 .map(|s| (s.start as usize, s.end as usize, fg_of(&s.slot)))
1397 .collect();
1398 let cells = annotation_cells(&a.annotation.text, &styled);
1399 lattice_cells::VirtualRow {
1400 anchor_line: a.row,
1401 // BELOW the row it describes — the graph hangs under its
1402 // habit. Design `org-habits.md` §5.
1403 position: lattice_cells::AnchorPosition::Below,
1404 cells: std::sync::Arc::from(cells),
1405 height: 1,
1406 // `Annotation`: scrolls with its anchor and paints no
1407 // backdrop. `Generic` carries the diff deletion-block
1408 // backdrop, which under an agenda row would read as a
1409 // removed line.
1410 kind: lattice_cells::VirtualRowKind::Annotation,
1411 bg: None,
1412 scales: None,
1413 media: None,
1414 gutter_line: None,
1415 gutter_fg: None,
1416 }
1417 })
1418 .collect()
1419 }
1420}
1421
1422/// The annotation's text as cells, with its spans applied.
1423///
1424/// Spans are BYTE offsets into the text (the seam's contract) while cells are
1425/// per character, so the two are walked together rather than indexed — an
1426/// annotation of box-drawing glyphs is multi-byte throughout, and indexing
1427/// cells by a byte offset would colour the wrong ones.
1428fn annotation_cells(text: &str, spans: &[(usize, usize, u32)]) -> Vec<lattice_cells::Cell> {
1429 text.char_indices()
1430 .map(|(byte, ch)| {
1431 // First match wins, the same precedence the syntax pipeline gives
1432 // overlapping spans.
1433 let fg = spans
1434 .iter()
1435 .find(|(start, end, _)| byte >= *start && byte < *end)
1436 .map(|(_, _, fg)| *fg)
1437 .unwrap_or(0);
1438 lattice_cells::Cell::new(ch as u32, fg, 0, 0)
1439 })
1440 .collect()
1441}
1442
1443/// The terminal state for a scan that produced nothing.
1444///
1445/// It still publishes [`MultibufferExcerptsReady`]: an empty agenda has to
1446/// repaint too, or the view keeps showing "Building agenda…" forever.
1447///
1448/// OA.0c: and it must CLEAR. Since `open_scan_view` stopped emptying the view
1449/// up front, this is the only thing standing between "you have nothing
1450/// scheduled" and a refresh that silently keeps showing yesterday's rows —
1451/// the worse of the two failures, because stale rows look exactly like
1452/// correct ones.
1453fn finish_empty(
1454 mb_registry: &MultibufferRegistryHandle,
1455 events: &Option<Arc<EventBus>>,
1456 view: BufferId,
1457 outcome: &ScanOutcome,
1458) {
1459 let Some(handle) = mb_registry.handle(view) else {
1460 return;
1461 };
1462 handle.replace_excerpts(HashMap::new(), Vec::new());
1463 handle.set_headerline(HeaderlineStatus::Complete {
1464 // The label matters MOST here. "nothing scheduled" under a filter the
1465 // user forgot is this view saying "you have no tasks" when they have
1466 // plenty — the single worst thing it can say incorrectly.
1467 summary: format!(
1468 "{}nothing scheduled ({} file(s) scanned){}",
1469 header_prefix("agenda", &outcome.label),
1470 outcome.files_scanned,
1471 outcome.caveat()
1472 ),
1473 // Accented here MORE than anywhere: "nothing scheduled" under a filter
1474 // is the sentence that misleads, and the filter is what has to catch
1475 // the eye beside it.
1476 emphasis: (!outcome.label.is_empty()).then(|| outcome.label.clone()),
1477 });
1478 if let Some(events) = events {
1479 events.publish_typed(MultibufferExcerptsReady { view });
1480 }
1481}
1482
1483// ─────────────────────────────────────────────────────────────────
1484// The view's own mode
1485// ─────────────────────────────────────────────────────────────────
1486
1487/// `agenda-view-mode` — the minor the provider activates on the agenda view.
1488///
1489/// The `ProjectSearchMode` shape (`org-mode.md` §4.2): `multibuffer-mode` is
1490/// the view's major, and the provider contributes a minor carrying what is
1491/// specific to *this* view.
1492///
1493/// ## Why this is native and not the plugin's `org-agenda-mode`
1494///
1495/// The design fragment gave `gr` to `org-agenda-mode`, which the plugin owns.
1496/// It cannot have it, for a reason that is structural rather than a matter of
1497/// taste: refreshing the agenda means re-running the HOST's walk, which is
1498/// `AppEffect::OpenProviderView` — and that effect's plugin surface is
1499/// **deliberately withheld** (`boundary_app_effect.rs`), pending the
1500/// capability model for which providers a plugin may trigger. A plugin
1501/// `gr` could bind the chord and not do the work.
1502///
1503/// It is also the better split on merit. Refreshing a host-built view is
1504/// host machinery: the second scanned-excerpt-source plugin — the markdown TODO
1505/// scanner the whole `extensions()` design exists for — inherits `gr` here,
1506/// where under the fragment's version every agenda plugin would re-derive it.
1507/// That is the copied-keymap failure the minor-mode rule forbids, one layer
1508/// up. What stays the plugin's is what is genuinely org: acting on a TODO
1509/// state from the agenda, through `org-agenda-mode`'s own chords and its own
1510/// handler bodies. Both modes are active on the view at once; neither is a
1511/// half-migration.
1512pub struct ScanViewMode;
1513
1514/// The refresh body this view declares. Named, not anonymous, because
1515/// `refresh_action` returns a *target* and the handler below must supply it —
1516/// declaring one without the other is the gap `magit-project-diff` shipped
1517/// with and PD.9 had to come back for.
1518pub const REFRESH_ACTION: &str = "action:scan-view-refresh";
1519
1520impl ScanViewMode {
1521 pub fn mode_id() -> lattice_mode::ModeId {
1522 lattice_mode::ModeId::new("scan-view-mode")
1523 }
1524}
1525
1526impl lattice_mode::Mode for ScanViewMode {
1527 type Guard = ();
1528
1529 fn id(&self) -> lattice_mode::ModeId {
1530 Self::mode_id()
1531 }
1532
1533 fn kind(&self) -> lattice_mode::ModeKind {
1534 lattice_mode::ModeKind::Minor
1535 }
1536
1537 /// Manual: the provider activates it on the view it just built. An
1538 /// activation policy could not express "the buffer this provider made",
1539 /// and a policy keyed on `BufferKind::Multibuffer` would attach it to
1540 /// every search and diff view too.
1541 fn activation_policy(&self) -> lattice_mode::ActivationPolicy {
1542 lattice_mode::ActivationPolicy::Manual
1543 }
1544
1545 /// Returning `Some` is the whole of the `gr` wiring: it pulls
1546 /// `refreshable-view-mode` in through the implies cascade, and that mode
1547 /// owns the chord. One line, no second thing to remember — which matters,
1548 /// because forgetting the second thing kills the chord silently.
1549 fn refresh_action(&self) -> Option<&'static str> {
1550 Some(REFRESH_ACTION)
1551 }
1552
1553 /// OA.4b: this view folds by blocks, so `<Tab>` / `<S-Tab>` come from the
1554 /// shared `foldable-view-mode`. Nothing special to do on a block, so it
1555 /// names the generic body.
1556 fn fold_toggle_action(&self) -> Option<&'static str> {
1557 Some(lattice_mode::FOLD_TOGGLE_DEFAULT_ACTION)
1558 }
1559
1560 /// OA.16: `cr` toggles the clock report.
1561 ///
1562 /// **The chord lives here and not on the mode it switches**, which is the
1563 /// one place the "modes own their full surface" rule cannot be read
1564 /// literally: a keymap layer is gated to buffers where its mode is
1565 /// active, so a `cr` on `scan-view-clockreport-mode` could turn the report
1566 /// off and never on. The switch belongs to the surface that offers it —
1567 /// this view — and everything the report *does* (registering its rows,
1568 /// its element vocabulary, tearing both down) stays with the mode. Same
1569 /// split as `refreshable-view-mode`, one layer over.
1570 ///
1571 /// `cr` rather than emacs' bare `R`: `R` is vim's replace-mode, and while
1572 /// a read-only view makes it inert today, taking a grammar letter for a
1573 /// display toggle is a debt the moment any scan view becomes editable.
1574 /// `c` is free here — the change operator has nothing to change — and `v`
1575 /// is already the agenda's span prefix, so a two-key form is the shape
1576 /// this view's chords already have.
1577 fn keymap(&self) -> lattice_mode::Keymap {
1578 lattice_mode::Keymap::from_entries(scan_view_keymap_entries())
1579 }
1580
1581 /// The mode that declares the target also supplies the body. Leaving the
1582 /// handler to the host would be the half-migration the standing rule
1583 /// forbids.
1584 fn action_handlers(&self) -> Vec<lattice_mode::ActionHandlerContribution> {
1585 vec![lattice_mode::ActionHandlerContribution {
1586 action_name: REFRESH_ACTION,
1587 handler: Arc::new(|ctx: &lattice_mode::ActionContext<'_>| {
1588 let view = BufferId(ctx.buffer_id.0 as u32);
1589 // Re-open with the root this view already shows. Reading it
1590 // back matters: `gr` in an agenda over `~/notes` must not
1591 // silently turn it into an agenda over the current checkout.
1592 //
1593 // `Args::None` is not a fallback to "the project root" — the
1594 // opener itself prefers the open view's stored state, so an
1595 // absent service degrades to the same answer by one path
1596 // instead of two that can disagree.
1597 let args = ctx
1598 .services
1599 .get::<ScanViewServiceHandle>()
1600 .and_then(|svc| svc.state(view))
1601 .and_then(|state| {
1602 state.read().ok().map(|s| match s.options.roots.first() {
1603 // AF.2: only a root the USER named is replayed.
1604 // Roots that came from a source are deliberately
1605 // NOT — re-asking picks up a `:set` of the
1606 // source's own option, so `gr` after editing
1607 // your agenda files shows the new set. What the
1608 // replay protects is the explicit argument, and
1609 // that is the case that arrives as one root.
1610 Some(root) if s.options.roots.len() == 1 => {
1611 Args::String(root.display().to_string())
1612 }
1613 _ => Args::None,
1614 })
1615 })
1616 .unwrap_or(Args::None);
1617 // The view's OWN provider, read back from its state — see
1618 // `ScanViewState::provider`. Without a state entry there is no
1619 // honest answer, and refreshing some other provider's view is
1620 // worse than declining to refresh this one.
1621 let provider = ctx
1622 .services
1623 .get::<ScanViewServiceHandle>()
1624 .and_then(|svc| svc.state(view))
1625 .and_then(|s| s.read().ok().map(|s| s.provider.clone()))?;
1626 Some(lattice_grammar::Effect::AppAction(
1627 lattice_grammar::app_effect::AppEffect::OpenProviderView { provider, args },
1628 ))
1629 }),
1630 }]
1631 }
1632
1633 fn on_activate(
1634 &self,
1635 _ctx: lattice_mode::ModeContext,
1636 ) -> lattice_mode::LifecycleFuture<'_, ()> {
1637 Box::pin(async { Ok(()) })
1638 }
1639}
1640
1641/// The view's own chords. One today; `gr` and `<Tab>` arrive through the
1642/// shared minors this mode declares targets for.
1643fn scan_view_keymap_entries() -> &'static [lattice_mode::KeymapEntry] {
1644 static ENTRIES: std::sync::OnceLock<Vec<lattice_mode::KeymapEntry>> =
1645 std::sync::OnceLock::new();
1646 ENTRIES.get_or_init(|| {
1647 vec![lattice_mode::keymap_entry! {
1648 mode: Normal,
1649 chord: "cr",
1650 doc: "Toggle the clock report",
1651 cmd: Some(crate::providers::clock_report::TOGGLE_ACTION)
1652 }]
1653 })
1654}
1655
1656// ─────────────────────────────────────────────────────────────────
1657// Boot integration
1658// ─────────────────────────────────────────────────────────────────
1659
1660/// Register the view's minor mode.
1661pub fn register_scan_view_mode(modes: &mut lattice_mode::ModeRegistry) {
1662 modes
1663 .register(ScanViewMode)
1664 .expect("scan-view-mode registers without conflict at boot");
1665 // OA.16: the clock report rides the same registration. A display mode the
1666 // user can never activate is not a feature, and registering it here means
1667 // one call site rather than a second thing to remember.
1668 modes
1669 .register(crate::providers::clock_report::ScanViewClockReportMode)
1670 .expect("scan-view-clockreport-mode registers without conflict at boot");
1671}
1672
1673/// Register `action:agenda-refresh` so the mode's `refresh_action` target
1674/// resolves. The body lives on the mode; this is the registry entry the host
1675/// looks the name up in.
1676pub fn register_scan_view_actions(registry: &mut CommandRegistry) {
1677 use lattice_grammar::effect::Effect;
1678 use lattice_grammar::registry::ActionSpec;
1679 registry.register_action(
1680 REFRESH_ACTION,
1681 "Re-scan the agenda over the root this view already shows.",
1682 ActionSpec {
1683 // A dead body, like `refreshable-view-mode`'s own: the mode's
1684 // registered handler is what runs. This exists so the name
1685 // resolves.
1686 apply: Arc::new(|_| Ok(Effect::None)),
1687 args_schema: vec![],
1688 },
1689 );
1690 // OA.16: `cr`'s target rides the same call, for the reason the mode
1691 // registration does — a chord whose command does not resolve is a chord
1692 // that silently does nothing, and the two things had to be remembered
1693 // together anyway.
1694 crate::providers::clock_report::register_clock_report_actions(registry);
1695}
1696
1697/// Register the per-view state service.
1698pub fn register_scan_view_service(services: &mut ServiceRegistry) {
1699 services.register(InMemoryScanViewService::handle());
1700}
1701
1702#[cfg(test)]
1703mod tests {
1704 #![allow(clippy::unwrap_used)]
1705 use super::*;
1706 use lattice_core::ViewArgsResolver as _;
1707
1708 fn state_with_args(args: &[&str]) -> ScanViewState {
1709 ScanViewState {
1710 provider: "agenda".to_string(),
1711 options: ScanViewOptions {
1712 scan_args: args.iter().map(|a| a.to_string()).collect(),
1713 ..Default::default()
1714 },
1715 clock: Vec::new(),
1716 annotations: Vec::new(),
1717 annotations_version: 0,
1718 }
1719 }
1720
1721 /// OA.27: the resolver answers a view's own arguments.
1722 ///
1723 /// The arguments ARE what the view is showing — span, day, filters, command
1724 /// — so a chord that changes one has to read the rest back. This is the
1725 /// read; the trigger already writes it on every open.
1726 #[test]
1727 fn the_resolver_answers_the_args_the_view_was_opened_with() {
1728 let service = InMemoryScanViewService::handle();
1729 let view = BufferId(7);
1730 service.set_state(view, state_with_args(&["", "span=30", "tag:work"]));
1731
1732 let resolver = ScanViewArgs::new(service);
1733 assert_eq!(
1734 resolver.view_args(view),
1735 Some(vec![
1736 String::new(),
1737 "span=30".to_string(),
1738 "tag:work".to_string()
1739 ])
1740 );
1741 }
1742
1743 /// A buffer that is not a scan view is `None`, not an empty list.
1744 ///
1745 /// The distinction dies at the WIT boundary (both cross as "no arguments")
1746 /// and is kept here because a resolver that answered `Some(vec![])` for
1747 /// every buffer in the editor would be indistinguishable from one that
1748 /// works, in exactly the tests meant to catch that.
1749 #[test]
1750 fn a_buffer_that_is_not_a_view_is_none() {
1751 let service = InMemoryScanViewService::handle();
1752 service.set_state(BufferId(7), state_with_args(&["", "span=30"]));
1753
1754 let resolver = ScanViewArgs::new(service);
1755 assert_eq!(resolver.view_args(BufferId(8)), None);
1756 }
1757
1758 /// A view opened with no arguments answers an empty list rather than
1759 /// `None` — it IS a view, and it is showing the default.
1760 #[test]
1761 fn a_view_with_no_args_is_some_and_empty() {
1762 let service = InMemoryScanViewService::handle();
1763 let view = BufferId(3);
1764 service.set_state(view, state_with_args(&[]));
1765
1766 let resolver = ScanViewArgs::new(service);
1767 assert_eq!(resolver.view_args(view), Some(Vec::new()));
1768 }
1769
1770 /// A unique scratch directory. Timestamp alone collides under parallel
1771 /// `cargo test`, so a counter rides with it.
1772 fn scratch(tag: &str) -> PathBuf {
1773 static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1774 let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1775 let nanos = std::time::SystemTime::now()
1776 .duration_since(std::time::UNIX_EPOCH)
1777 .map(|d| d.as_nanos())
1778 .unwrap_or(0);
1779 let dir = std::env::temp_dir().join(format!("lattice-agenda-walk-{tag}-{nanos}-{n}"));
1780 std::fs::create_dir_all(&dir).unwrap();
1781 dir
1782 }
1783
1784 /// OA.0d: a configured directory means the files IN it. Emacs expands a
1785 /// directory entry in `org-agenda-files` one level, and an org directory
1786 /// with `roam/` or `archive/` beneath it should not drag those in.
1787 #[test]
1788 fn a_configured_directory_does_not_pull_in_its_subtree() {
1789 let dir = scratch("subtree");
1790 std::fs::write(dir.join("a.org"), "* TODO a\n").unwrap();
1791 std::fs::create_dir_all(dir.join("sub")).unwrap();
1792 std::fs::write(dir.join("sub").join("b.org"), "* TODO b\n").unwrap();
1793
1794 let exts = vec!["org".to_string()];
1795 let found = collect_candidates(std::slice::from_ref(&dir), &exts, usize::MAX);
1796 let names: Vec<String> = found
1797 .iter()
1798 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
1799 .collect();
1800 assert_eq!(names, vec!["a.org"], "found: {found:?}");
1801
1802 // The escape hatch: naming the subdirectory opts it in, which is the
1803 // whole reason this needs no recursion flag.
1804 let both = collect_candidates(&[dir.clone(), dir.join("sub")], &exts, usize::MAX);
1805 assert_eq!(both.len(), 2, "found: {both:?}");
1806
1807 let _ = std::fs::remove_dir_all(&dir);
1808 }
1809
1810 /// The one thing the recursive walk did right, kept: `ignore`'s filtering
1811 /// still applies at the single level, so dotfiles stay out.
1812 #[test]
1813 fn the_single_level_walk_still_skips_hidden_files() {
1814 let dir = scratch("hidden");
1815 std::fs::write(dir.join("a.org"), "* TODO a\n").unwrap();
1816 std::fs::write(dir.join(".hidden.org"), "* TODO h\n").unwrap();
1817
1818 let found =
1819 collect_candidates(std::slice::from_ref(&dir), &["org".to_string()], usize::MAX);
1820 let names: Vec<String> = found
1821 .iter()
1822 .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
1823 .collect();
1824 assert_eq!(names, vec!["a.org"], "found: {found:?}");
1825
1826 let _ = std::fs::remove_dir_all(&dir);
1827 }
1828
1829 /// A file named directly is still taken, whatever its depth — the roots
1830 /// list holds files as well as directories, and only the DIRECTORY
1831 /// expansion changed.
1832 #[test]
1833 fn a_file_named_directly_is_still_taken() {
1834 let dir = scratch("file");
1835 std::fs::create_dir_all(dir.join("deep")).unwrap();
1836 let deep = dir.join("deep").join("c.org");
1837 std::fs::write(&deep, "* TODO c\n").unwrap();
1838
1839 let found = collect_candidates(
1840 std::slice::from_ref(&deep),
1841 &["org".to_string()],
1842 usize::MAX,
1843 );
1844 assert_eq!(found, vec![deep]);
1845
1846 let _ = std::fs::remove_dir_all(&dir);
1847 }
1848
1849 /// OA.5: the two translations `composed_row_spans` owns, on the layout
1850 /// that makes them differ — two files interleaved by the sort, so a row's
1851 /// composed index is NOT its index among its own file's rows.
1852 #[test]
1853 fn row_spans_land_on_the_composed_row_with_line_relative_offsets() {
1854 let mut a = entry(7, "day-1", "Day 1", 1);
1855 a.spans = vec![lattice_mode::scanned_excerpt_source::RowSpan {
1856 start: 2,
1857 end: 6,
1858 slot: "keyword".to_string(),
1859 }];
1860 let mut b = entry(3, "day-2", "Day 2", 2);
1861 b.spans = vec![lattice_mode::scanned_excerpt_source::RowSpan {
1862 start: 0,
1863 end: 4,
1864 slot: "comment".to_string(),
1865 }];
1866 // Row `a` is line 7 of file 0; row `b` is line 3 of file 1. Sorted,
1867 // `a` is composed row 0 and `b` composed row 1 — neither matching the
1868 // source line either carries.
1869 let rows = vec![
1870 SortedRow { file: 0, entry: a },
1871 SortedRow { file: 1, entry: b },
1872 ];
1873 let src_a = BufferId::next();
1874 let src_b = BufferId::next();
1875 let excerpts = vec![Excerpt::new(src_a, 7, 7), Excerpt::new(src_b, 3, 3)];
1876
1877 let out = composed_row_spans(&rows, &excerpts, None);
1878
1879 assert_eq!(out.len(), 2, "one entry per composed row");
1880 assert_eq!(out[0].len(), 1);
1881 assert_eq!(
1882 (out[0][0].start, out[0][0].end),
1883 (2, 6),
1884 "offsets stay relative to the row's own line, not rebased"
1885 );
1886 assert_eq!(out[1].len(), 1);
1887 assert_eq!((out[1][0].start, out[1][0].end), (0, 4));
1888 assert_ne!(
1889 out[0][0].style, out[1][0].style,
1890 "each slot resolves to its own style"
1891 );
1892 }
1893
1894 // ── HB.5: annotations ───────────────────────────────────────────────
1895
1896 fn annotated(line: u32, group: &str, key: i64, text: &str) -> ScannedExcerpt {
1897 let mut e = entry(line, group, group, key);
1898 e.annotation = Some(lattice_mode::RowAnnotation {
1899 text: text.to_string(),
1900 spans: vec![lattice_mode::scanned_excerpt_source::RowSpan {
1901 start: 0,
1902 end: 3,
1903 slot: "org.habit.ready".to_string(),
1904 }],
1905 });
1906 e
1907 }
1908
1909 /// The translation, on the layout that makes it differ from the obvious
1910 /// one: two files interleaved by the sort, so a row's composed index is
1911 /// NOT its index among its own file's rows. A single-file fixture passes
1912 /// against the wrong arithmetic.
1913 #[test]
1914 fn annotations_land_on_the_composed_row_not_the_source_line() {
1915 let rows = vec![
1916 SortedRow {
1917 file: 0,
1918 entry: annotated(7, "day-1", 1, "aaa"),
1919 },
1920 SortedRow {
1921 file: 1,
1922 entry: annotated(3, "day-2", 2, "bbb"),
1923 },
1924 ];
1925 let excerpts = vec![
1926 Excerpt::new(BufferId::next(), 7, 7),
1927 Excerpt::new(BufferId::next(), 3, 3),
1928 ];
1929
1930 let out = composed_row_annotations(&rows, &excerpts);
1931 assert_eq!(
1932 out.iter().map(|a| a.row).collect::<Vec<_>>(),
1933 vec![0, 1],
1934 "composed rows 0 and 1 — neither is the source line either row carries"
1935 );
1936 assert_eq!(out[0].annotation.text, "aaa");
1937 assert_eq!(out[1].annotation.text, "bbb");
1938 }
1939
1940 /// Only annotated rows produce entries, and they keep the composed index of
1941 /// the row they belong to rather than being packed together. A row with no
1942 /// annotation must not shift the ones after it.
1943 #[test]
1944 fn an_unannotated_row_produces_nothing_and_shifts_nothing() {
1945 let rows = vec![
1946 SortedRow {
1947 file: 0,
1948 entry: entry(0, "g", "G", 1),
1949 },
1950 SortedRow {
1951 file: 0,
1952 entry: annotated(1, "g", 2, "graph"),
1953 },
1954 ];
1955 let excerpts = vec![
1956 Excerpt::new(BufferId::next(), 0, 0),
1957 Excerpt::new(BufferId::next(), 1, 1),
1958 ];
1959
1960 let out = composed_row_annotations(&rows, &excerpts);
1961 assert_eq!(out.len(), 1, "only the annotated row contributes");
1962 assert_eq!(
1963 out[0].row, 1,
1964 "and it keeps ITS composed row, not the index it has among annotations"
1965 );
1966 }
1967
1968 /// A multi-line row pushes the rows after it down, and an annotation has to
1969 /// follow — otherwise the graph hangs under somebody else's habit. The same
1970 /// hazard `a_multi_line_row_does_not_shift_the_row_after_it` pins for spans.
1971 #[test]
1972 fn a_multi_line_row_moves_the_annotation_after_it() {
1973 let rows = vec![
1974 SortedRow {
1975 file: 0,
1976 entry: entry(0, "g", "G", 1),
1977 },
1978 SortedRow {
1979 file: 0,
1980 entry: annotated(5, "g", 2, "graph"),
1981 },
1982 ];
1983 // The first excerpt covers TWO lines, so the second row is composed
1984 // row 2, not row 1.
1985 let excerpts = vec![
1986 Excerpt::new(BufferId::next(), 0, 1),
1987 Excerpt::new(BufferId::next(), 5, 5),
1988 ];
1989
1990 let out = composed_row_annotations(&rows, &excerpts);
1991 assert_eq!(out.len(), 1);
1992 assert_eq!(out[0].row, 2);
1993 }
1994
1995 /// The provider reads the state through the SERVICE, so a refresh that
1996 /// replaces the state Arc is still seen. Registering with a captured Arc
1997 /// would freeze the rows at the first scan and read as "the graph stopped
1998 /// updating".
1999 #[test]
2000 fn the_provider_follows_a_state_replacement() {
2001 use lattice_cells::VirtualRowProvider;
2002
2003 let svc: ScanViewServiceHandle = InMemoryScanViewService::handle();
2004 let view = BufferId::next();
2005 let state = |text: &str, version: u64| ScanViewState {
2006 provider: "p".to_string(),
2007 options: ScanViewOptions::default(),
2008 clock: Vec::new(),
2009 annotations: vec![RowAnnotationAt {
2010 row: 0,
2011 annotation: lattice_mode::RowAnnotation {
2012 text: text.to_string(),
2013 spans: Vec::new(),
2014 },
2015 }],
2016 annotations_version: version,
2017 };
2018 svc.set_state(view, state("first", 1));
2019 let provider = RowAnnotationProvider::new(view, svc.clone(), None);
2020 let text_of = |p: &RowAnnotationProvider| -> String {
2021 p.collect()[0]
2022 .cells
2023 .iter()
2024 .filter_map(|c| char::from_u32(c.codepoint))
2025 .collect()
2026 };
2027 assert_eq!(text_of(&provider), "first");
2028 let before = provider.version();
2029
2030 // What `open_scan_view` does on a refresh: a WHOLE NEW Arc.
2031 svc.set_state(view, state("second", 2));
2032 assert_eq!(
2033 text_of(&provider),
2034 "second",
2035 "the provider must follow the replacement, not hold the old state"
2036 );
2037 assert_ne!(
2038 provider.version(),
2039 before,
2040 "and say so, or the worker caches the previous rows"
2041 );
2042 }
2043
2044 /// The rows the provider emits: one per annotation, anchored BELOW its row,
2045 /// and painting no backdrop of its own.
2046 #[test]
2047 fn the_provider_emits_one_row_below_each_annotated_row() {
2048 use lattice_cells::VirtualRowProvider;
2049
2050 let svc: ScanViewServiceHandle = InMemoryScanViewService::handle();
2051 let view = BufferId::next();
2052 svc.set_state(
2053 view,
2054 ScanViewState {
2055 provider: "p".to_string(),
2056 options: ScanViewOptions::default(),
2057 clock: Vec::new(),
2058 annotations: vec![RowAnnotationAt {
2059 row: 4,
2060 annotation: lattice_mode::RowAnnotation {
2061 text: "···".to_string(),
2062 spans: Vec::new(),
2063 },
2064 }],
2065 annotations_version: 1,
2066 },
2067 );
2068 let rows = RowAnnotationProvider::new(view, svc, None).collect();
2069 assert_eq!(rows.len(), 1);
2070 assert_eq!(rows[0].anchor_line, 4);
2071 assert_eq!(rows[0].position, lattice_cells::AnchorPosition::Below);
2072 assert_eq!(rows[0].height, 1);
2073 assert_eq!(rows[0].kind, lattice_cells::VirtualRowKind::Annotation);
2074 assert!(
2075 rows[0].bg.is_none(),
2076 "an annotation paints no backdrop; `Generic`'s would read as a deleted line"
2077 );
2078 assert_eq!(
2079 rows[0].cells.len(),
2080 3,
2081 "one cell per CHARACTER, not per byte"
2082 );
2083 }
2084
2085 /// A view with no state registers nothing rather than an empty row — the
2086 /// provider is registered at open, before the first scan has run.
2087 #[test]
2088 fn a_view_with_no_state_emits_no_rows() {
2089 use lattice_cells::VirtualRowProvider;
2090 let svc: ScanViewServiceHandle = InMemoryScanViewService::handle();
2091 let provider = RowAnnotationProvider::new(BufferId::next(), svc, None);
2092 assert!(provider.collect().is_empty());
2093 assert_eq!(provider.version(), 0);
2094 }
2095
2096 /// Spans are BYTE offsets and cells are per character. An annotation of
2097 /// box-drawing glyphs is multi-byte throughout, so indexing cells by a byte
2098 /// offset would colour the wrong ones — and the graph is exactly that kind
2099 /// of string.
2100 #[test]
2101 fn spans_colour_by_byte_offset_over_multibyte_text() {
2102 // `·` is U+00B7 — TWO bytes in UTF-8 — so "···" occupies bytes 0, 2
2103 // and 4. A span of `0..2` therefore covers the first char and nothing
2104 // else; an implementation that indexed cells by the byte offset would
2105 // colour the first TWO.
2106 assert_eq!("·".len(), 2, "the premise this test rests on");
2107 let cells = annotation_cells("···", &[(0, 2, 0xAA_BB_CC)]);
2108 assert_eq!(cells.len(), 3, "one cell per char");
2109 assert_eq!(cells[0].fg, 0xAA_BB_CC, "byte 0 is inside 0..2");
2110 assert_eq!(
2111 cells[1].fg, 0,
2112 "the second char starts at byte 2, which is past the span"
2113 );
2114 assert_eq!(cells[2].fg, 0);
2115
2116 // And a span wide enough for two chars colours exactly two.
2117 let cells = annotation_cells("···", &[(0, 4, 0x11_22_33)]);
2118 assert_eq!(
2119 cells.iter().map(|c| c.fg).collect::<Vec<_>>(),
2120 vec![0x11_22_33, 0x11_22_33, 0]
2121 );
2122 }
2123
2124 /// A multi-line row must not smear its spans over the rows below it, and
2125 /// the row after it must still land at the right composed index.
2126 #[test]
2127 fn a_multi_line_row_does_not_shift_the_row_after_it() {
2128 let mut a = entry(0, "g", "G", 1);
2129 a.spans = vec![lattice_mode::scanned_excerpt_source::RowSpan {
2130 start: 0,
2131 end: 3,
2132 slot: "keyword".to_string(),
2133 }];
2134 let mut b = entry(9, "g", "", 2);
2135 b.spans = vec![lattice_mode::scanned_excerpt_source::RowSpan {
2136 start: 1,
2137 end: 2,
2138 slot: "comment".to_string(),
2139 }];
2140 let rows = vec![
2141 SortedRow { file: 0, entry: a },
2142 SortedRow { file: 0, entry: b },
2143 ];
2144 let src = BufferId::next();
2145 // The first excerpt spans TWO lines, so the second row is composed
2146 // row 2 rather than row 1.
2147 let excerpts = vec![Excerpt::new(src, 0, 1), Excerpt::new(src, 9, 9)];
2148
2149 let out = composed_row_spans(&rows, &excerpts, None);
2150
2151 assert_eq!(out.len(), 3, "two lines plus one: {out:?}");
2152 assert_eq!(out[0].len(), 1, "the first row's span is on its head line");
2153 assert!(out[1].is_empty(), "its second line carries nothing");
2154 assert_eq!(out[2].len(), 1, "the next row is at composed row 2");
2155 }
2156
2157 fn entry(line: u32, group: &str, label: &str, sort_key: i64) -> ScannedExcerpt {
2158 ScannedExcerpt {
2159 line,
2160 end_line: line,
2161 group: group.to_string(),
2162 label: label.to_string(),
2163 sort_key,
2164 spans: Vec::new(),
2165 annotation: None,
2166 emphasis: false,
2167 }
2168 }
2169
2170 fn file(path: &str, entries: Vec<ScannedExcerpt>) -> FileRows {
2171 FileRows {
2172 path: PathBuf::from(path),
2173 text: "x\n".repeat(20),
2174 entries,
2175 }
2176 }
2177
2178 /// The property the whole design turns on: rows from DIFFERENT files
2179 /// interleave by `sort_key`, so a date group can span files.
2180 #[test]
2181 fn rows_sort_across_files_not_within_them() {
2182 let files = vec![
2183 file("/p/a.org", vec![entry(1, "wed", "Wed", 30)]),
2184 file("/p/b.org", vec![entry(2, "mon", "Mon", 10)]),
2185 file("/p/c.org", vec![entry(3, "tue", "Tue", 20)]),
2186 ];
2187 let rows = sort_rows(&files);
2188 assert_eq!(
2189 rows.iter().map(|r| r.entry.sort_key).collect::<Vec<_>>(),
2190 vec![10, 20, 30]
2191 );
2192 }
2193
2194 /// Equal keys keep walk order, so two same-day rows in one file do not
2195 /// shuffle between scans.
2196 #[test]
2197 fn equal_sort_keys_keep_walk_order() {
2198 let files = vec![file(
2199 "/p/a.org",
2200 vec![
2201 entry(5, "mon", "Mon", 10),
2202 entry(2, "mon", "Mon", 10),
2203 entry(9, "mon", "Mon", 10),
2204 ],
2205 )];
2206 let rows = sort_rows(&files);
2207 assert_eq!(
2208 rows.iter().map(|r| r.entry.line).collect::<Vec<_>>(),
2209 vec![5, 2, 9]
2210 );
2211 }
2212
2213 fn ids(n: usize) -> HashMap<usize, BufferId> {
2214 (0..n).map(|i| (i, BufferId(i as u32 + 1))).collect()
2215 }
2216
2217 /// §6.1's grouping mechanism: one header per run, the rest empty — which
2218 /// is how a date group drawn from three files renders one header.
2219 #[test]
2220 fn only_the_first_row_of_a_group_carries_a_header() {
2221 let files = vec![
2222 file("/p/a.org", vec![entry(1, "mon", "Monday", 10)]),
2223 file("/p/b.org", vec![entry(2, "mon", "Monday", 11)]),
2224 file("/p/c.org", vec![entry(3, "tue", "Tuesday", 20)]),
2225 ];
2226 let rows = sort_rows(&files);
2227 let excerpts = build_excerpts(&rows, &ids(3));
2228 let titles: Vec<&str> = excerpts.iter().map(|e| e.header.title.as_str()).collect();
2229 assert_eq!(titles, vec!["Monday", "", "Tuesday"]);
2230 }
2231
2232 /// A group that reappears after another group gets a fresh header. It is
2233 /// a *run* test, not a seen-set test: a source is free to interleave, and
2234 /// suppressing the second "Monday" would silently merge two runs.
2235 #[test]
2236 fn a_group_that_recurs_after_another_gets_a_new_header() {
2237 let files = vec![file(
2238 "/p/a.org",
2239 vec![
2240 entry(1, "mon", "Monday", 10),
2241 entry(2, "tue", "Tuesday", 20),
2242 entry(3, "mon", "Monday", 30),
2243 ],
2244 )];
2245 let rows = sort_rows(&files);
2246 let excerpts = build_excerpts(&rows, &ids(1));
2247 let titles: Vec<&str> = excerpts.iter().map(|e| e.header.title.as_str()).collect();
2248 assert_eq!(titles, vec!["Monday", "Tuesday", "Monday"]);
2249 }
2250
2251 /// MH.A6: emphasis reaches the header of the group that asked for it, and
2252 /// only that one.
2253 ///
2254 /// The negative half is the assertion that matters. A `build_excerpts`
2255 /// that ignored the flag and emphasised nothing would pass a
2256 /// today-is-emphasised test written the other way round only if it
2257 /// checked the positive; a version that emphasised every header would
2258 /// pass the positive too. Both halves together pin it.
2259 #[test]
2260 fn only_the_group_that_asked_for_it_is_emphasised() {
2261 let mut today = entry(2, "tue", "Tuesday (today)", 20);
2262 today.emphasis = true;
2263 let files = vec![file(
2264 "/p/a.org",
2265 vec![
2266 entry(1, "mon", "Monday", 10),
2267 today,
2268 entry(3, "wed", "Wednesday", 30),
2269 ],
2270 )];
2271 let rows = sort_rows(&files);
2272 let excerpts = build_excerpts(&rows, &ids(1));
2273 let styles: Vec<ExcerptHeaderStyle> = excerpts.iter().map(|e| e.header.style).collect();
2274 assert_eq!(
2275 styles,
2276 vec![
2277 ExcerptHeaderStyle::Default,
2278 ExcerptHeaderStyle::Emphasis,
2279 ExcerptHeaderStyle::Default,
2280 ]
2281 );
2282 }
2283
2284 /// The flag is read from the row that STARTS the group, like the label —
2285 /// and a continuation row renders no header at all, so it cannot smuggle
2286 /// an emphasis onto a group whose first row did not ask for one.
2287 #[test]
2288 fn a_continuation_row_cannot_emphasise_its_group() {
2289 let mut second = entry(2, "mon", "Monday", 11);
2290 second.emphasis = true;
2291 let files = vec![
2292 file("/p/a.org", vec![entry(1, "mon", "Monday", 10)]),
2293 file("/p/b.org", vec![second]),
2294 ];
2295 let rows = sort_rows(&files);
2296 let excerpts = build_excerpts(&rows, &ids(2));
2297 assert_eq!(
2298 excerpts[0].header.style,
2299 ExcerptHeaderStyle::Default,
2300 "the group's own first row did not ask to be emphasised"
2301 );
2302 assert!(
2303 excerpts[1].header.title.is_empty(),
2304 "and the row that did starts no group, so it renders no header"
2305 );
2306 }
2307
2308 /// Every row of one file points at ONE source document, or an edit made
2309 /// through one row would not be visible through its neighbour.
2310 #[test]
2311 fn rows_from_one_file_share_a_source() {
2312 let files = vec![file(
2313 "/p/a.org",
2314 vec![entry(1, "mon", "Monday", 10), entry(7, "mon", "Monday", 11)],
2315 )];
2316 let rows = sort_rows(&files);
2317 let excerpts = build_excerpts(&rows, &ids(1));
2318 assert_eq!(excerpts.len(), 2);
2319 assert_eq!(excerpts[0].source, excerpts[1].source);
2320 }
2321
2322 /// The walk's cheapness guarantee: a file nobody claims is never even a
2323 /// candidate, so `:agenda` in a Rust checkout costs a directory walk.
2324 #[test]
2325 fn only_claimed_extensions_become_candidates() {
2326 let exts = vec!["org".to_string()];
2327 assert!(claims_any(Path::new("/p/notes.org"), &exts));
2328 assert!(claims_any(Path::new("/p/NOTES.ORG"), &exts));
2329 assert!(!claims_any(Path::new("/p/main.rs"), &exts));
2330 assert!(!claims_any(Path::new("/p/Makefile"), &exts));
2331 }
2332
2333 #[test]
2334 fn a_tilde_root_expands_against_home() {
2335 // SAFETY-adjacent: only reads the var, and the assertion is relative
2336 // to whatever it is, so this does not depend on the test environment.
2337 let Some(home) = std::env::var_os("HOME") else {
2338 return;
2339 };
2340 let home = PathBuf::from(home);
2341 assert_eq!(
2342 shellexpand_tilde("~/notes"),
2343 home.join("notes").display().to_string()
2344 );
2345 assert_eq!(shellexpand_tilde("~"), home.display().to_string());
2346 assert_eq!(shellexpand_tilde("/abs/path"), "/abs/path");
2347 }
2348
2349 // ─────────────────────────────────────────────────────────────
2350 // The scan, end to end against a fake producer
2351 // ─────────────────────────────────────────────────────────────
2352
2353 /// Records the paths it was offered, so a test can assert what the walk
2354 /// did NOT read as well as what it did.
2355 #[derive(Debug)]
2356 struct FakeSource {
2357 id: u64,
2358 exts: Vec<String>,
2359 begins: Arc<std::sync::atomic::AtomicUsize>,
2360 offered: Arc<std::sync::Mutex<Vec<PathBuf>>>,
2361 /// OA.11a: the scan args `begin` was handed, so a test can assert the
2362 /// view's arguments reached the producer rather than only that the
2363 /// scan ran.
2364 begin_args: Arc<std::sync::Mutex<Vec<String>>>,
2365 }
2366
2367 impl FakeSource {
2368 fn new(id: u64, exts: &[&str]) -> Self {
2369 Self {
2370 id,
2371 exts: exts.iter().map(|e| e.to_string()).collect(),
2372 begins: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2373 offered: Arc::new(std::sync::Mutex::new(Vec::new())),
2374 begin_args: Arc::new(std::sync::Mutex::new(Vec::new())),
2375 }
2376 }
2377 }
2378
2379 impl lattice_mode::ScannedExcerptSource for FakeSource {
2380 fn source_id(&self) -> u64 {
2381 self.id
2382 }
2383 fn extensions(&self) -> &[String] {
2384 &self.exts
2385 }
2386 fn begin(&self, args: &[String]) -> lattice_mode::ScanBeginFuture<'_> {
2387 self.begins
2388 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2389 if let Ok(mut a) = self.begin_args.lock() {
2390 *a = args.to_vec();
2391 }
2392 Box::pin(async { Ok(()) })
2393 }
2394 fn scan(&self, path: PathBuf, text: String) -> lattice_mode::ScanFuture<'_> {
2395 if let Ok(mut o) = self.offered.lock() {
2396 o.push(path.clone());
2397 }
2398 Box::pin(async move {
2399 if text.contains("BROKEN") {
2400 return Err("fake: malformed".to_string());
2401 }
2402 // One row per `* TODO <key>` line, keyed off the file so the
2403 // sort assertion is testing the producer's data.
2404 Ok(lattice_mode::ScanResult::rows(
2405 text.lines()
2406 .enumerate()
2407 .filter_map(|(i, line)| {
2408 let rest = line.strip_prefix("* TODO ")?;
2409 let key: i64 = rest.trim().parse().ok()?;
2410 Some(ScannedExcerpt {
2411 line: i as u32,
2412 end_line: i as u32,
2413 group: format!("day-{key}"),
2414 label: format!("Day {key}"),
2415 sort_key: key,
2416 spans: Vec::new(),
2417 annotation: None,
2418 emphasis: false,
2419 })
2420 })
2421 .collect(),
2422 ))
2423 })
2424 }
2425 }
2426
2427 fn view_handle() -> (MultibufferRegistryHandle, BufferId) {
2428 let registry = crate::registry::InMemoryMultibufferRegistry::handle();
2429 let handle = Arc::new(crate::MultibufferDocumentHandle::empty(Arc::new(
2430 arc_swap::ArcSwap::from_pointee(CommandRegistry::new()),
2431 )));
2432 let view = handle.buffer_id();
2433 registry.insert(view, handle);
2434 (registry, view)
2435 }
2436
2437 fn write(dir: &Path, name: &str, body: &str) {
2438 std::fs::write(dir.join(name), body).expect("fixture write");
2439 }
2440
2441 /// Poll until the spawned scan reaches a terminal headerline. The scan
2442 /// hops through `spawn_blocking` twice, so there is no single future to
2443 /// await from here.
2444 async fn settle_agenda(
2445 registry: &MultibufferRegistryHandle,
2446 view: BufferId,
2447 ) -> HeaderlineStatus {
2448 for _ in 0..400 {
2449 if let Some(h) = registry.handle(view) {
2450 let status = (*h.headerline()).clone();
2451 if matches!(
2452 status,
2453 HeaderlineStatus::Complete { .. } | HeaderlineStatus::Failed { .. }
2454 ) {
2455 return status;
2456 }
2457 }
2458 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2459 }
2460 panic!("the agenda scan never reached a terminal headerline");
2461 }
2462
2463 fn tempdir() -> PathBuf {
2464 // Unique per call: a timestamp alone collides under parallel `cargo
2465 // test`, so a counter rides along ([[tempdir-helpers-need-a-counter]]).
2466 static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2467 let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2468 let nanos = std::time::SystemTime::now()
2469 .duration_since(std::time::UNIX_EPOCH)
2470 .map(|d| d.as_nanos())
2471 .unwrap_or(0);
2472 let dir = std::env::temp_dir().join(format!("lattice-agenda-{nanos}-{n}"));
2473 std::fs::create_dir_all(&dir).expect("tempdir");
2474 dir
2475 }
2476
2477 /// OA.11a: a trigger's arguments split into the host's slot and the
2478 /// guest's, positionally.
2479 ///
2480 /// The cases that matter are the two that predate the slice — a bare open
2481 /// and a root — because they must be untouched, and the two that motivate
2482 /// it: a command key with no root, and a root and a command key together.
2483 #[test]
2484 fn view_args_split_into_a_root_and_the_guests_own() {
2485 use lattice_grammar::args::ArgValue;
2486
2487 // Unchanged by this slice: every trigger that existed before it.
2488 assert_eq!(split_view_args(&Args::None), (String::new(), Vec::new()));
2489 assert_eq!(
2490 split_view_args(&Args::String(" ~/notes ".to_string())),
2491 ("~/notes".to_string(), Vec::new()),
2492 "trimmed, and still a root"
2493 );
2494
2495 // A command key with no root — the dispatcher's ordinary case. Position
2496 // 0 is empty rather than missing, which is what keeps the list
2497 // positional instead of making the first element ambiguous.
2498 assert_eq!(
2499 split_view_args(&Args::List(vec![
2500 ArgValue::String(String::new()),
2501 ArgValue::String("waiting".to_string()),
2502 ])),
2503 (String::new(), vec!["waiting".to_string()]),
2504 "no root override, one scan arg"
2505 );
2506
2507 // Both at once: neither consumes the other. This is the case a
2508 // single-slot design cannot express at all.
2509 assert_eq!(
2510 split_view_args(&Args::List(vec![
2511 ArgValue::String("~/notes".to_string()),
2512 ArgValue::String("waiting".to_string()),
2513 ])),
2514 ("~/notes".to_string(), vec!["waiting".to_string()])
2515 );
2516 }
2517
2518 /// OA.11a: scan args are sticky across a re-open, exactly as roots are.
2519 ///
2520 /// `gr` re-enters through the opener, so an agenda opened for one command
2521 /// must not quietly revert to the default one on refresh. Replaced only
2522 /// when a new open supplies its own — which is how the dispatcher moves
2523 /// you between commands.
2524 #[test]
2525 fn a_re_open_without_args_keeps_the_command_it_was_opened_for() {
2526 use lattice_grammar::args::ArgValue;
2527
2528 // What the opener does to a stored set of options, in the same order.
2529 let apply = |options: &mut ScanViewOptions, args: &Args| {
2530 let (root, scan_args) = split_view_args(args);
2531 if !root.is_empty() {
2532 options.roots = vec![PathBuf::from(shellexpand_tilde(&root))];
2533 }
2534 if !scan_args.is_empty() {
2535 options.scan_args = scan_args;
2536 }
2537 };
2538
2539 let mut options = ScanViewOptions::default();
2540 apply(
2541 &mut options,
2542 &Args::List(vec![
2543 ArgValue::String(String::new()),
2544 ArgValue::String("waiting".to_string()),
2545 ]),
2546 );
2547 assert_eq!(options.scan_args, vec!["waiting".to_string()]);
2548
2549 // A refresh carries no arguments and must not lose the command.
2550 apply(&mut options, &Args::None);
2551 assert_eq!(
2552 options.scan_args,
2553 vec!["waiting".to_string()],
2554 "`gr` keeps the agenda you chose"
2555 );
2556
2557 // Choosing another command replaces it.
2558 apply(
2559 &mut options,
2560 &Args::List(vec![
2561 ArgValue::String(String::new()),
2562 ArgValue::String("refile".to_string()),
2563 ]),
2564 );
2565 assert_eq!(options.scan_args, vec!["refile".to_string()]);
2566 }
2567
2568 /// OA.14b: a file with NO agenda rows still contributes its clocked time.
2569 ///
2570 /// The property the whole seam exists for, and the one an implementation
2571 /// that hung clock data off rows cannot have: a corpus can hold a week of
2572 /// logged time and not a single agenda row. Asserted through
2573 /// `spawn_scan_view_scan` so it covers the driver's own filter — an earlier
2574 /// draft collected clock only inside the `!entries.is_empty()` branch,
2575 /// which passes every row test and loses exactly this file.
2576 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2577 async fn a_file_with_no_rows_still_reports_its_clocked_time() {
2578 let dir = tempdir();
2579 // No `* TODO` line anywhere: the fake source yields zero rows.
2580 write(&dir, "notes.org", "* Notes\nsome prose\n");
2581
2582 let (registry, view) = view_handle();
2583 let mut registry_builder = lattice_mode::ServiceRegistry::new();
2584 let svc = InMemoryScanViewService::handle();
2585 registry_builder.register::<ScanViewServiceHandle>(svc.clone());
2586 let services = Arc::new(registry_builder);
2587 svc.set_state(
2588 view,
2589 ScanViewState {
2590 provider: "test".to_string(),
2591 options: ScanViewOptions::default(),
2592 clock: Vec::new(),
2593 annotations: Vec::new(),
2594 annotations_version: 0,
2595 },
2596 );
2597
2598 spawn_scan_view_scan(
2599 view,
2600 ScanViewOptions {
2601 roots: vec![dir.clone()],
2602 max_files: None,
2603 ..Default::default()
2604 },
2605 vec![Arc::new(ClockOnlySource {
2606 exts: vec!["org".to_string()],
2607 })],
2608 registry.clone(),
2609 None,
2610 Some(services),
2611 );
2612 settle_agenda(®istry, view).await;
2613
2614 let state = svc.state(view).expect("the view has state");
2615 let clock = state.read().expect("readable").clock.clone();
2616 assert_eq!(
2617 clock
2618 .iter()
2619 .map(|c| (c.outline.clone(), c.minutes))
2620 .collect::<Vec<_>>(),
2621 vec![(vec!["Notes".to_string()], 90)],
2622 "the clocked time survives a file that produced no rows"
2623 );
2624 }
2625
2626 /// A source with time but no rows — the shape the test above needs and the
2627 /// one a row-attached design could not express at all.
2628 #[derive(Debug)]
2629 struct ClockOnlySource {
2630 exts: Vec<String>,
2631 }
2632
2633 impl lattice_mode::ScannedExcerptSource for ClockOnlySource {
2634 fn source_id(&self) -> u64 {
2635 42
2636 }
2637 fn extensions(&self) -> &[String] {
2638 &self.exts
2639 }
2640 fn begin(&self, _args: &[String]) -> lattice_mode::ScanBeginFuture<'_> {
2641 Box::pin(async { Ok(()) })
2642 }
2643 fn scan(&self, _p: PathBuf, _t: String) -> lattice_mode::ScanFuture<'_> {
2644 Box::pin(async {
2645 Ok(lattice_mode::ScanResult {
2646 entries: Vec::new(),
2647 clock: vec![lattice_mode::ClockSpan {
2648 line: 0,
2649 outline: vec!["Notes".to_string()],
2650 day: 20_000,
2651 minutes: 90,
2652 }],
2653 })
2654 })
2655 }
2656 }
2657
2658 /// OA.11a: the view's scan args reach every source's `begin`.
2659 ///
2660 /// The walk is untouched by them — this asserts the same file set is
2661 /// offered either way — because that is the whole point of the two-slot
2662 /// split. `roots` is the host's parameter and drives the walk; `scan_args`
2663 /// are the guest's and drive nothing the host does. A single-slot design
2664 /// fails here by turning the command key into a root and offering no files
2665 /// at all.
2666 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2667 async fn the_views_scan_args_reach_every_source() {
2668 let dir = tempdir();
2669 write(&dir, "a.org", "* TODO 1\n");
2670
2671 let (registry, view) = view_handle();
2672 let source = Arc::new(FakeSource::new(1, &["org"]));
2673 let begin_args = source.begin_args.clone();
2674 let offered = source.offered.clone();
2675
2676 spawn_scan_view_scan(
2677 view,
2678 ScanViewOptions {
2679 roots: vec![dir.clone()],
2680 max_files: None,
2681 scan_args: vec!["waiting".to_string()],
2682 },
2683 vec![source],
2684 registry.clone(),
2685 None,
2686 None,
2687 );
2688 settle_agenda(®istry, view).await;
2689
2690 assert_eq!(
2691 *begin_args.lock().unwrap(),
2692 vec!["waiting".to_string()],
2693 "the args the view carries are handed to the producer verbatim"
2694 );
2695 assert_eq!(
2696 offered.lock().unwrap().len(),
2697 1,
2698 "and the walk is unchanged by them — scan args parameterise the \
2699 GUEST, not the file set"
2700 );
2701 }
2702
2703 /// The headline assertion: rows from three files land in one view, in the
2704 /// producer's global order, with one header per date group.
2705 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2706 async fn a_scan_interleaves_rows_from_every_file_in_sort_order() {
2707 let dir = tempdir();
2708 write(&dir, "a.org", "* TODO 30\n");
2709 write(&dir, "b.org", "* TODO 10\n* TODO 30\n");
2710 write(&dir, "c.org", "* TODO 20\n");
2711 // Never offered: nothing claims `.rs`.
2712 write(&dir, "main.rs", "* TODO 1\n");
2713
2714 let (registry, view) = view_handle();
2715 let source = Arc::new(FakeSource::new(1, &["org"]));
2716 let offered = source.offered.clone();
2717
2718 spawn_scan_view_scan(
2719 view,
2720 ScanViewOptions {
2721 roots: vec![dir.clone()],
2722 max_files: None,
2723 ..Default::default()
2724 },
2725 vec![source],
2726 registry.clone(),
2727 None,
2728 None,
2729 );
2730
2731 let status = settle_agenda(®istry, view).await;
2732 let handle = registry.handle(view).unwrap();
2733 let excerpts = handle.excerpts();
2734
2735 assert_eq!(
2736 excerpts.len(),
2737 4,
2738 "one row per `* TODO` line, got {status:?}"
2739 );
2740 // Sorted across files: 10 (b), 20 (c), 30 (a), 30 (b).
2741 assert_eq!(
2742 excerpts
2743 .iter()
2744 .map(|e| e.header.title.clone())
2745 .collect::<Vec<_>>(),
2746 vec![
2747 "Day 10".to_string(),
2748 "Day 20".to_string(),
2749 "Day 30".to_string(),
2750 // Same group as the row above it, drawn from a DIFFERENT file
2751 // — the property §6.1 turns on.
2752 String::new(),
2753 ]
2754 );
2755
2756 let offered = offered.lock().unwrap().clone();
2757 assert!(
2758 !offered
2759 .iter()
2760 .any(|p| p.extension().and_then(|e| e.to_str()) == Some("rs")),
2761 "a file no source claims is never read, let alone crossed: {offered:?}"
2762 );
2763
2764 let _ = std::fs::remove_dir_all(&dir);
2765 }
2766
2767 /// One malformed file must not fail the agenda: the other files' rows
2768 /// still land. `error-parser`'s rule, same failure class.
2769 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2770 async fn a_file_the_source_rejects_is_skipped_and_the_scan_continues() {
2771 let dir = tempdir();
2772 write(&dir, "bad.org", "BROKEN\n");
2773 write(&dir, "good.org", "* TODO 5\n");
2774
2775 let (registry, view) = view_handle();
2776 spawn_scan_view_scan(
2777 view,
2778 ScanViewOptions {
2779 roots: vec![dir.clone()],
2780 max_files: None,
2781 ..Default::default()
2782 },
2783 vec![Arc::new(FakeSource::new(1, &["org"]))],
2784 registry.clone(),
2785 None,
2786 None,
2787 );
2788
2789 settle_agenda(®istry, view).await;
2790 let excerpts = registry.handle(view).unwrap().excerpts();
2791 assert_eq!(excerpts.len(), 1);
2792 assert_eq!(excerpts[0].header.title, "Day 5");
2793
2794 let _ = std::fs::remove_dir_all(&dir);
2795 }
2796
2797 /// An empty result still reaches a terminal headerline. Leaving it on
2798 /// "Building agenda…" forever is the failure this pins — the view would
2799 /// look permanently mid-scan.
2800 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2801 async fn an_empty_agenda_still_finishes_its_headerline() {
2802 let dir = tempdir();
2803 write(&dir, "prose.org", "just some prose\n");
2804
2805 let (registry, view) = view_handle();
2806 spawn_scan_view_scan(
2807 view,
2808 ScanViewOptions {
2809 roots: vec![dir.clone()],
2810 max_files: None,
2811 ..Default::default()
2812 },
2813 vec![Arc::new(FakeSource::new(1, &["org"]))],
2814 registry.clone(),
2815 None,
2816 None,
2817 );
2818
2819 match settle_agenda(®istry, view).await {
2820 HeaderlineStatus::Complete { summary, .. } => {
2821 assert!(summary.contains("nothing scheduled"), "got {summary}");
2822 }
2823 other => panic!("expected a Complete headerline, got {other:?}"),
2824 }
2825
2826 let _ = std::fs::remove_dir_all(&dir);
2827 }
2828
2829 /// `begin` runs once per scan, before any file — the contract the guest's
2830 /// per-scan state depends on.
2831 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2832 async fn every_source_begins_exactly_once_per_scan() {
2833 let dir = tempdir();
2834 write(&dir, "a.org", "* TODO 1\n");
2835 write(&dir, "b.org", "* TODO 2\n");
2836
2837 let (registry, view) = view_handle();
2838 let source = Arc::new(FakeSource::new(1, &["org"]));
2839 let begins = source.begins.clone();
2840
2841 spawn_scan_view_scan(
2842 view,
2843 ScanViewOptions {
2844 roots: vec![dir.clone()],
2845 max_files: None,
2846 ..Default::default()
2847 },
2848 vec![source],
2849 registry.clone(),
2850 None,
2851 None,
2852 );
2853
2854 settle_agenda(®istry, view).await;
2855 assert_eq!(begins.load(std::sync::atomic::Ordering::SeqCst), 1);
2856
2857 let _ = std::fs::remove_dir_all(&dir);
2858 }
2859
2860 // ─────────────────────────────────────────────────────────────
2861 // OM.A3 — the view mode and the partial-scan contract
2862 // ─────────────────────────────────────────────────────────────
2863
2864 /// A source that errors on EVERY file — a quarantined plugin, which is
2865 /// the failure this defends against.
2866 #[derive(Debug)]
2867 struct DeadSource {
2868 exts: Vec<String>,
2869 calls: Arc<std::sync::atomic::AtomicUsize>,
2870 }
2871
2872 impl lattice_mode::ScannedExcerptSource for DeadSource {
2873 fn source_id(&self) -> u64 {
2874 99
2875 }
2876 fn extensions(&self) -> &[String] {
2877 &self.exts
2878 }
2879 fn begin(&self, _args: &[String]) -> lattice_mode::ScanBeginFuture<'_> {
2880 Box::pin(async { Ok(()) })
2881 }
2882 fn scan(&self, _p: PathBuf, _t: String) -> lattice_mode::ScanFuture<'_> {
2883 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2884 Box::pin(async { Err("quarantined".to_string()) })
2885 }
2886 }
2887
2888 /// §8's partial-and-honest rule, with the second half that a bare row
2889 /// count would lose: an agenda missing a source's rows looks exactly like
2890 /// an agenda that never had any, so the headerline has to SAY it is
2891 /// partial.
2892 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2893 async fn a_source_that_stops_answering_leaves_partial_rows_and_an_honest_headerline() {
2894 let dir = tempdir();
2895 for i in 0..10 {
2896 write(&dir, &format!("n{i}.org"), "* TODO 1\n");
2897 }
2898
2899 let (registry, view) = view_handle();
2900 let dead = Arc::new(DeadSource {
2901 exts: vec!["org".to_string()],
2902 calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
2903 });
2904 let calls = dead.calls.clone();
2905
2906 spawn_scan_view_scan(
2907 view,
2908 ScanViewOptions {
2909 roots: vec![dir.clone()],
2910 max_files: None,
2911 ..Default::default()
2912 },
2913 // The healthy source still contributes: one bad producer must not
2914 // cost the user the other's rows.
2915 vec![Arc::new(FakeSource::new(1, &["org"])), dead],
2916 registry.clone(),
2917 None,
2918 None,
2919 );
2920
2921 let status = settle_agenda(®istry, view).await;
2922 assert_eq!(
2923 registry.handle(view).unwrap().excerpts().len(),
2924 10,
2925 "the healthy source's rows survived"
2926 );
2927 match status {
2928 HeaderlineStatus::Complete { summary, .. } => assert!(
2929 summary.contains("partial") && summary.contains("stopped responding"),
2930 "the headerline must say the agenda is incomplete, got {summary}"
2931 ),
2932 other => panic!("expected a Complete headerline, got {other:?}"),
2933 }
2934
2935 // …and it stopped asking. A quarantined plugin answers the same way
2936 // forever, so continuing costs a channel round-trip per remaining
2937 // file to learn nothing.
2938 assert_eq!(
2939 calls.load(std::sync::atomic::Ordering::SeqCst),
2940 SOURCE_FAILURE_BUDGET as usize,
2941 "the scan gave up after the budget rather than asking all ten"
2942 );
2943 }
2944
2945 /// A few bad files in a large project is NOT a dead source. The budget
2946 /// counts CONSECUTIVE failures, so a good file in between resets it —
2947 /// otherwise a project with three malformed org files anywhere in it
2948 /// would silently lose its agenda.
2949 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2950 async fn scattered_bad_files_do_not_drop_a_healthy_source() {
2951 let dir = tempdir();
2952 // Alternating, so no three failures ever land in a row. The index
2953 // LEADS the name: the walk is in file-name order, and `bad0…bad6`
2954 // sort ahead of every `good`, which is four failures in a row.
2955 for i in 0..8 {
2956 if i % 2 == 0 {
2957 write(&dir, &format!("{i}-bad.org"), "BROKEN\n");
2958 } else {
2959 write(&dir, &format!("{i}-good.org"), "* TODO 1\n");
2960 }
2961 }
2962
2963 let (registry, view) = view_handle();
2964 spawn_scan_view_scan(
2965 view,
2966 ScanViewOptions {
2967 roots: vec![dir.clone()],
2968 max_files: None,
2969 ..Default::default()
2970 },
2971 vec![Arc::new(FakeSource::new(1, &["org"]))],
2972 registry.clone(),
2973 None,
2974 None,
2975 );
2976
2977 let status = settle_agenda(®istry, view).await;
2978 assert_eq!(
2979 registry.handle(view).unwrap().excerpts().len(),
2980 4,
2981 "every good file still contributed"
2982 );
2983 match status {
2984 HeaderlineStatus::Complete { summary, .. } => {
2985 assert!(
2986 !summary.contains("stopped responding"),
2987 "a source with scattered bad files is alive, got {summary}"
2988 );
2989 assert!(
2990 summary.contains("4 file(s) skipped"),
2991 "…but the skips are still reported, got {summary}"
2992 );
2993 }
2994 other => panic!("expected a Complete headerline, got {other:?}"),
2995 }
2996
2997 let _ = std::fs::remove_dir_all(&dir);
2998 }
2999
3000 /// `gr` has to have a target AND a body. Declaring the first and leaving
3001 /// the second to the host is the half-migration the standing rule
3002 /// forbids — and it is exactly what `magit-project-diff` shipped with,
3003 /// where the chord resolved to nothing and failed silently.
3004 #[test]
3005 fn gr_resolves_to_this_views_own_refresh() {
3006 use lattice_mode::Mode;
3007 let m = ScanViewMode;
3008 assert_eq!(m.kind(), lattice_mode::ModeKind::Minor);
3009 assert!(
3010 matches!(
3011 m.activation_policy(),
3012 lattice_mode::ActivationPolicy::Manual
3013 ),
3014 "the provider activates it on the view it built; no policy can say that"
3015 );
3016 // The cascade keys on `refresh_action` being `Some`, NOT on an
3017 // `implies()` entry — deliberately, so a forgotten list entry cannot
3018 // kill the chord as silently as the three copied `gr` keymaps it
3019 // replaced (RV.1). One line is the whole contract, and this is it.
3020 assert_eq!(
3021 m.refresh_action(),
3022 Some(REFRESH_ACTION),
3023 "the chord arrives through the cascade because this is Some"
3024 );
3025 assert!(
3026 m.action_handlers()
3027 .iter()
3028 .any(|c| c.action_name == REFRESH_ACTION),
3029 "…whose body this mode supplies"
3030 );
3031 }
3032
3033 /// OA.16: `cr` is bound HERE, on the surface that offers the report — not
3034 /// on the mode it switches, whose layer is gated to buffers where it is
3035 /// already active and so could only ever turn the report off.
3036 #[test]
3037 fn cr_toggles_the_clock_report_from_this_view() {
3038 use lattice_mode::Mode;
3039 let entries = ScanViewMode.keymap().entries;
3040 let cr = entries
3041 .iter()
3042 .find(|e| e.chord == "cr")
3043 .expect("the view offers the clock report");
3044 assert_eq!(
3045 cr.command,
3046 Some(crate::providers::clock_report::TOGGLE_ACTION)
3047 );
3048 assert!(
3049 entries.iter().all(|e| e.chord != "R"),
3050 "not emacs' bare R — that is vim's replace-mode, and taking a \
3051 grammar letter for a display toggle is a debt the moment a scan \
3052 view becomes editable"
3053 );
3054 }
3055
3056 /// …and the target resolves, or the chord silently does nothing. Boot
3057 /// registers both through `register_scan_view_actions`; this is the
3058 /// assertion that they stay together.
3059 #[test]
3060 fn the_clock_report_toggle_is_registered_with_this_views_actions() {
3061 let mut reg = CommandRegistry::new();
3062 register_scan_view_actions(&mut reg);
3063 assert!(
3064 reg.lookup_by_name(crate::providers::clock_report::TOGGLE_ACTION)
3065 .is_some(),
3066 "cr's target must be in the registry the keymap resolves against"
3067 );
3068 }
3069
3070 #[test]
3071 fn service_state_roundtrips_and_clears() {
3072 let svc = InMemoryScanViewService::new();
3073 let view = BufferId(3);
3074 assert!(svc.state(view).is_none());
3075 svc.set_state(
3076 view,
3077 ScanViewState {
3078 provider: "test".to_string(),
3079 options: ScanViewOptions {
3080 roots: vec![PathBuf::from("/p")],
3081 max_files: Some(10),
3082 ..Default::default()
3083 },
3084 clock: Vec::new(),
3085 annotations: Vec::new(),
3086 annotations_version: 0,
3087 },
3088 );
3089 let got = svc.state(view).unwrap();
3090 assert_eq!(got.read().unwrap().options.roots, vec![PathBuf::from("/p")]);
3091 svc.clear(view);
3092 assert!(svc.state(view).is_none());
3093 }
3094}
3095
3096#[cfg(test)]
3097mod af2_roots {
3098 #![allow(clippy::unwrap_used, clippy::panic)]
3099 use super::*;
3100
3101 /// The sibling module's helper, restated rather than imported: it is
3102 /// `#[cfg(test)]` in a private module, and a counter is what keeps parallel
3103 /// `cargo test` runs from colliding ([[tempdir-helpers-need-a-counter]]).
3104 fn tempdir() -> PathBuf {
3105 static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
3106 let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3107 let nanos = std::time::SystemTime::now()
3108 .duration_since(std::time::UNIX_EPOCH)
3109 .map(|d| d.as_nanos())
3110 .unwrap_or(0);
3111 let dir = std::env::temp_dir().join(format!("lattice-af2-{nanos}-{n}"));
3112 std::fs::create_dir_all(&dir).expect("tempdir");
3113 dir
3114 }
3115
3116 fn touch(dir: &Path, name: &str) -> PathBuf {
3117 let p = dir.join(name);
3118 if let Some(parent) = p.parent() {
3119 std::fs::create_dir_all(parent).unwrap();
3120 }
3121 std::fs::write(&p, "* TODO x\n").unwrap();
3122 p
3123 }
3124
3125 fn names(paths: &[PathBuf]) -> Vec<String> {
3126 let mut v: Vec<String> = paths
3127 .iter()
3128 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
3129 .collect();
3130 v.sort();
3131 v
3132 }
3133
3134 /// The two shapes one list has to carry: a directory that gets walked, and
3135 /// a file taken as given. Both are ordinary org usage — `org-agenda-files`
3136 /// holding `org-directory` plus a single `anniversaries.org` is the case
3137 /// this exists for.
3138 #[test]
3139 fn a_root_may_be_a_directory_or_a_file() {
3140 let base = tempdir();
3141 let notes = base.join("notes");
3142 std::fs::create_dir_all(¬es).unwrap();
3143 touch(¬es, "a.org");
3144 touch(¬es, "b.org");
3145 let loose = touch(&base, "anniversaries.org");
3146 // A file the walk would NOT have offered: nothing claims `.txt`.
3147 // Naming it is the claim, which is why the extension test is skipped
3148 // for a root that is a file.
3149 let odd = base.join("birthdays.txt");
3150 std::fs::write(&odd, "* TODO y\n").unwrap();
3151
3152 let exts = vec!["org".to_string()];
3153 let got = collect_candidates(&[notes.clone(), loose, odd], &exts, usize::MAX);
3154 assert_eq!(
3155 names(&got),
3156 vec!["a.org", "anniversaries.org", "b.org", "birthdays.txt"]
3157 );
3158 }
3159
3160 /// A file inside a configured directory is an ordinary way to write
3161 /// "everything here, and that one too" — and must not scan twice, or its
3162 /// rows appear twice in the view.
3163 #[test]
3164 fn a_file_inside_a_configured_directory_is_not_scanned_twice() {
3165 let base = tempdir();
3166 let notes = base.join("notes");
3167 std::fs::create_dir_all(¬es).unwrap();
3168 let a = touch(¬es, "a.org");
3169
3170 let exts = vec!["org".to_string()];
3171 let got = collect_candidates(&[notes, a], &exts, usize::MAX);
3172 assert_eq!(names(&got), vec!["a.org"], "deduplicated across roots");
3173 }
3174
3175 /// One bad entry in a config list is the same failure class as one bad
3176 /// file: skip it and scan the rest. Refusing the whole agenda because a
3177 /// path was renamed would be the worse answer by far.
3178 #[test]
3179 fn a_configured_path_that_is_gone_does_not_fail_the_scan() {
3180 let base = tempdir();
3181 let notes = base.join("notes");
3182 std::fs::create_dir_all(¬es).unwrap();
3183 touch(¬es, "a.org");
3184
3185 let exts = vec!["org".to_string()];
3186 let got = collect_candidates(&[base.join("does-not-exist"), notes], &exts, usize::MAX);
3187 assert_eq!(names(&got), vec!["a.org"]);
3188 }
3189
3190 /// The cap bounds the UNION. A cap that reset per root would not be a
3191 /// bound, which is the whole reason it exists — the walk is unattended.
3192 #[test]
3193 fn the_file_cap_applies_across_roots_not_per_root() {
3194 let base = tempdir();
3195 let one = base.join("one");
3196 let two = base.join("two");
3197 std::fs::create_dir_all(&one).unwrap();
3198 std::fs::create_dir_all(&two).unwrap();
3199 touch(&one, "a.org");
3200 touch(&one, "b.org");
3201 touch(&two, "c.org");
3202 touch(&two, "d.org");
3203
3204 let exts = vec!["org".to_string()];
3205 let got = collect_candidates(&[one, two], &exts, 3);
3206 assert_eq!(got.len(), 3, "three across both roots, not three from each");
3207 }
3208
3209 /// With nothing configured the options are EMPTY, not the project root —
3210 /// the fallback moved into the scan, where every source has been asked.
3211 /// A default that still baked in the project root would mean a source's
3212 /// roots could never be reached.
3213 #[test]
3214 fn the_default_names_no_root_so_the_scan_can_ask() {
3215 assert!(
3216 ScanViewOptions::default().roots.is_empty(),
3217 "the fallback belongs to the scan, which is the only place that \
3218 has heard from the sources"
3219 );
3220 // …and the view's scope still resolves to something usable.
3221 assert!(
3222 !ScanViewOptions::default()
3223 .scope_dir()
3224 .as_os_str()
3225 .is_empty()
3226 );
3227 }
3228}