Skip to main content

vissue_core/
catalog.rs

1//! In-memory query facade over a parsed issue catalog.
2
3use chrono::Local;
4use std::collections::{HashMap, HashSet};
5use std::fs;
6
7use crate::config::Layout;
8use crate::error::{Error, Result};
9use crate::graph::DependencyGraph;
10use crate::model::{IssueHeading, READY_STATES};
11use crate::related::related_hits_from;
12use crate::report::parse_org_date;
13use crate::store::{IssueDoc, list_projects, project_selected};
14use crate::views::{
15    AgendaRow, ClaimRow, Excerpt, IssueDetail, IssueRec, IssueRow, ListQuery, Recall, RecallInput,
16    SearchHit, TreeNode, WalkHit,
17};
18
19pub(crate) const BODY_EXCERPT_MAX_LINES: usize = 40;
20pub(crate) const BODY_EXCERPT_MAX_CHARS: usize = 4000;
21
22/// Snapshot every heading across every project, with the path `detail` and
23/// `excerpt` need.
24///
25/// # Errors
26///
27/// Returns an error if a project directory cannot be listed or an
28/// `issues.org` cannot be read or parsed.
29pub fn load_recs(layout: &Layout) -> Result<Vec<IssueRec>> {
30    // See `store::load_all`: one file per project, nothing shared, and the
31    // collect keeps project order.
32    use rayon::prelude::*;
33    let per_project: Vec<Vec<IssueRec>> = list_projects(layout)?
34        .into_par_iter()
35        .map(|project| {
36            let path = layout.project_issues_path(&project);
37            let doc = IssueDoc::parse_file(&project, &path)?;
38            let tag_settings = doc.tag_settings;
39            Ok(doc
40                .headings
41                .into_iter()
42                .map(|heading| IssueRec {
43                    project: project.clone(),
44                    heading,
45                    path: path.clone(),
46                    tag_settings: tag_settings.clone(),
47                })
48                .collect())
49        })
50        .collect::<Result<Vec<_>>>()?;
51    Ok(per_project.into_iter().flatten().collect())
52}
53
54/// Read-only queries over a cached `&[IssueRec]`.
55#[derive(Debug)]
56pub struct CatalogService<'a> {
57    issues: &'a [IssueRec],
58}
59
60impl<'a> CatalogService<'a> {
61    /// Query over an already-loaded catalog snapshot.
62    pub fn from_recs(issues: &'a [IssueRec]) -> Self {
63        Self { issues }
64    }
65
66    fn rec(&self, id: &str) -> Result<&IssueRec> {
67        self.issues
68            .iter()
69            .find(|r| r.heading.id == id)
70            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })
71    }
72
73    /// List rows matching `q`, same filters and sort as [`issues_rows_from`].
74    ///
75    /// # Errors
76    ///
77    /// Does not fail for a parsed catalog.
78    pub fn issues_rows(&self, q: ListQuery) -> Result<Vec<IssueRow>> {
79        issues_rows_from(self.issues, q)
80    }
81
82    /// Actionable issues: TODO or STARTED with no open blocker.
83    ///
84    /// # Errors
85    ///
86    /// Does not fail for a parsed catalog.
87    pub fn ready(&self, project: Option<&str>) -> Result<Vec<IssueRow>> {
88        issues_rows_from(
89            self.issues,
90            ListQuery {
91                project: project.map(str::to_string),
92                ready: true,
93                ..ListQuery::default()
94            },
95        )
96    }
97
98    /// One issue as a detail card, including body and logbook.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if `id` is not in the catalog.
103    pub fn detail(&self, id: &str) -> Result<IssueDetail> {
104        Ok(issue_detail(self.rec(id)?))
105    }
106
107    /// On-disk heading range, capped and screened for secrets.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if `id` is not in the catalog, or the heading's file
112    /// cannot be read.
113    pub fn excerpt(&self, id: &str) -> Result<Excerpt> {
114        excerpt_from(self.rec(id)?)
115    }
116
117    /// Case-insensitive substring scan over id, title, properties, and body.
118    ///
119    /// # Errors
120    ///
121    /// Does not fail for a parsed catalog.
122    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
123        search_hits_from(self.issues, query, limit)
124    }
125
126    /// Live claims, oldest first, optionally narrowed by holder or project.
127    ///
128    /// # Errors
129    ///
130    /// Does not fail for a parsed catalog.
131    pub fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>> {
132        claims_from(self.issues, holder, project)
133    }
134
135    /// Dated open work in the next `days` days, plus anything already overdue.
136    ///
137    /// # Errors
138    ///
139    /// Does not fail for a parsed catalog.
140    pub fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>> {
141        agenda_rows_from(self.issues, days, project)
142    }
143
144    /// Parent/child subtree rooted at `id`.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if `id` is not in the catalog.
149    pub fn tree(&self, id: &str) -> Result<TreeNode> {
150        tree_from(self.issues, id)
151    }
152
153    /// Ranked related issues for `id`.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if `id` is not in the catalog.
158    pub fn related(
159        &self,
160        id: &str,
161        depth: usize,
162        limit: usize,
163    ) -> Result<Vec<crate::views::RelatedHit>> {
164        related_hits_from(self.issues, id, depth, limit)
165    }
166
167    /// Issues whose `:PARENT:` points at `id`.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if `id` is not in the catalog and no children exist.
172    pub fn children(&self, id: &str) -> Result<Vec<WalkHit>> {
173        children_from(self.issues, id)
174    }
175
176    /// Transitive blocker ancestors, limited to `depth` hops.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if `id` is not in the catalog, or the blocker graph
181    /// cannot be built.
182    pub fn ancestors(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
183        walk_from(self.issues, id, depth, WalkKind::Ancestors)
184    }
185
186    /// Transitive issues waiting on `id`, limited to `depth` hops.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if `id` is not in the catalog, or the blocker graph
191    /// cannot be built.
192    pub fn impact(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
193        walk_from(self.issues, id, depth, WalkKind::Impact)
194    }
195
196    /// The working set for `id`: plan, declared inputs and their deeds, and
197    /// what `id` has produced.
198    ///
199    /// # Errors
200    ///
201    /// Returns an error if `id` is not in the catalog, or the blocker graph
202    /// cannot be built.
203    pub fn recall(&self, id: &str, depth: usize, excerpts: bool) -> Result<crate::views::Recall> {
204        recall_from(self.issues, id, depth, excerpts)
205    }
206
207    /// Issues that refer to `id` through an edge, a parent, a discovered-from
208    /// or pivoted-to property, or a body mention.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error if `id` is not in the catalog and no backlinks exist.
213    pub fn backlinks(&self, id: &str) -> Result<Vec<WalkHit>> {
214        backlinks_from(self.issues, id)
215    }
216}
217
218/// List/ready rows, same filters and sort as [`crate::agent::issues_json`].
219///
220/// # Errors
221///
222/// Does not fail for a parsed catalog.
223pub fn issues_rows_from(issues: &[IssueRec], q: ListQuery) -> Result<Vec<IssueRow>> {
224    let active_blockers: HashSet<&str> = if q.ready {
225        issues
226            .iter()
227            .filter(|r| r.heading.state != "DONE" && r.heading.state != "CANCELLED")
228            .map(|r| r.heading.id.as_str())
229            .collect()
230    } else {
231        HashSet::new()
232    };
233    // Built once for the whole call rather than walked per issue. The ordered
234    // check needs the parent of an issue and then that parent's other children,
235    // and finding each by scanning made `ready` quadratic in the corpus on any
236    // tracker whose issues have parents, which is every tracker with a plan in
237    // it. `ready` is the verb an agent polls.
238    let ordering = q.ready.then(|| OrderingIndex::new(issues));
239
240    let mut rows: Vec<(char, String, String, IssueRow)> = Vec::new();
241    for rec in issues {
242        if !project_selected(&rec.project, q.project.as_deref()) {
243            continue;
244        }
245        if let Some(state) = q.state.as_deref()
246            && rec.heading.state != state
247        {
248            continue;
249        }
250        if q.ready {
251            if !READY_STATES.contains(&rec.heading.state.as_str()) {
252                continue;
253            }
254            if rec
255                .heading
256                .blocked_by()
257                .iter()
258                .any(|b| active_blockers.contains(b.as_str()))
259            {
260                continue;
261            }
262            if ordering
263                .as_ref()
264                .is_some_and(|index| ordered_sibling_holds(rec, index))
265            {
266                continue;
267            }
268        }
269        if let Some(needle) = q.query.as_deref()
270            && !list_query_matches(rec, needle)
271        {
272            continue;
273        }
274        rows.push((
275            rec.heading.priority,
276            rec.heading.state.clone(),
277            rec.heading.id.clone(),
278            issue_row(rec),
279        ));
280    }
281    rows.sort_by(|a, b| {
282        a.0.cmp(&b.0)
283            .then_with(|| a.1.cmp(&b.1))
284            .then_with(|| a.2.cmp(&b.2))
285    });
286    let mut out: Vec<IssueRow> = rows.into_iter().map(|r| r.3).collect();
287    let offset = q.offset.unwrap_or(0);
288    if offset >= out.len() {
289        out.clear();
290    } else if offset > 0 {
291        out = out.split_off(offset);
292    }
293    if let Some(limit) = q.limit {
294        out.truncate(limit);
295    }
296    Ok(out)
297}
298
299/// Parents and their children, indexed once for a `ready` call.
300struct OrderingIndex<'a> {
301    by_id: HashMap<&'a str, &'a IssueRec>,
302    children: HashMap<&'a str, Vec<&'a IssueRec>>,
303}
304
305impl<'a> OrderingIndex<'a> {
306    fn new(issues: &'a [IssueRec]) -> Self {
307        let mut by_id = HashMap::with_capacity(issues.len());
308        let mut children: HashMap<&str, Vec<&IssueRec>> = HashMap::new();
309        for rec in issues {
310            by_id.insert(rec.heading.id.as_str(), rec);
311            if let Some(parent) = rec.heading.parent() {
312                children.entry(parent).or_default().push(rec);
313            }
314        }
315        Self { by_id, children }
316    }
317}
318
319fn ordered_sibling_holds(rec: &IssueRec, index: &OrderingIndex<'_>) -> bool {
320    if crate::org::org_property_is_set(&rec.heading.properties, "NOBLOCKING") {
321        return false;
322    }
323    let Some(parent_id) = rec.heading.parent() else {
324        return false;
325    };
326    let Some(parent) = index.by_id.get(parent_id) else {
327        return false;
328    };
329    if !crate::org::org_property_is_set(&parent.heading.properties, "ORDERED") {
330        return false;
331    }
332    index
333        .children
334        .get(parent_id)
335        .into_iter()
336        .flatten()
337        .any(|sib| {
338            sib.heading.id != rec.heading.id
339                && sib.heading.line_start < rec.heading.line_start
340                && sib.heading.state != "DONE"
341                && sib.heading.state != "CANCELLED"
342        })
343}
344
345fn list_query_matches(rec: &IssueRec, needle: &str) -> bool {
346    let h = &rec.heading;
347    let needle = needle.to_lowercase();
348    if h.id.to_lowercase().contains(&needle) || h.title.to_lowercase().contains(&needle) {
349        return true;
350    }
351    if rec.tag_settings.matches_query(&h.tags(), &needle) {
352        return true;
353    }
354    h.properties
355        .iter()
356        .any(|(k, v)| k.to_lowercase().contains(&needle) || v.to_lowercase().contains(&needle))
357}
358
359fn issue_row(rec: &IssueRec) -> IssueRow {
360    IssueRow {
361        id: rec.heading.id.clone(),
362        state: rec.heading.state.clone(),
363        priority: rec.heading.priority.to_string(),
364        title: rec.heading.title.clone(),
365        project: rec.project.clone(),
366        blocked_by: rec.heading.blocked_by(),
367        claimed_by: rec.heading.claimed_by().map(str::to_string),
368        claimed_at: rec.heading.claimed_at().map(str::to_string),
369        parent: rec.heading.parent().map(str::to_string),
370    }
371}
372
373fn issue_detail(rec: &IssueRec) -> IssueDetail {
374    IssueDetail {
375        id: rec.heading.id.clone(),
376        project: rec.project.clone(),
377        title: rec.heading.title.clone(),
378        state: rec.heading.state.clone(),
379        priority: rec.heading.priority.to_string(),
380        properties: rec.heading.properties.clone(),
381        org_tags: rec.heading.org_tags.clone(),
382        deeds: rec.heading.deeds(),
383        tags: rec.tag_settings.all_tags(&rec.heading.tags()),
384        blocked_by: rec.heading.blocked_by(),
385        parent: rec.heading.parent().map(str::to_string),
386        claimed_by: rec.heading.claimed_by().map(str::to_string),
387        claimed_at: rec.heading.claimed_at().map(str::to_string),
388        file: format!(
389            "{}:{}-{}",
390            rec.path.display(),
391            rec.heading.line_start,
392            rec.heading.line_end
393        ),
394        line_start: rec.heading.line_start,
395        line_end: rec.heading.line_end,
396        body: rec.heading.body.trim_end().to_string(),
397        logbook: rec
398            .heading
399            .logbook
400            .iter()
401            .map(|e| crate::views::LogbookLine {
402                timestamp: e.timestamp.clone(),
403                from_state: e.from_state.clone(),
404                to_state: e.to_state.clone(),
405                note: e.note.clone(),
406                raw: e.raw.clone(),
407            })
408            .collect(),
409    }
410}
411
412/// On-disk heading range, capped and screened for secrets.
413///
414/// # Errors
415///
416/// Returns an error if the heading's file cannot be read.
417pub fn excerpt_from(rec: &IssueRec) -> Result<Excerpt> {
418    let content = fs::read_to_string(&rec.path)?;
419    let lines: Vec<&str> = content.lines().collect();
420    let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
421    let to = rec
422        .heading
423        .line_end
424        .min(lines.len())
425        .min(from + BODY_EXCERPT_MAX_LINES);
426    let mut text = lines[from..to].join("\n");
427    if text.len() > BODY_EXCERPT_MAX_CHARS {
428        text.truncate(BODY_EXCERPT_MAX_CHARS);
429        text.push_str("\n...");
430    }
431    let suppressed = match secret_marker(&text) {
432        Some(marker) => {
433            text = format!(
434                "(excerpt suppressed: {marker} looks like secret material; open {} directly)\n",
435                rec.path.display()
436            );
437            true
438        }
439        None => false,
440    };
441    Ok(Excerpt {
442        id: rec.heading.id.clone(),
443        file: rec.path.display().to_string(),
444        line_start: rec.heading.line_start,
445        line_end: rec.heading.line_end,
446        text,
447        suppressed,
448    })
449}
450
451/// The heading's on-disk text in full, screened for secrets.
452///
453/// [`excerpt_from`] caps its output at the preview line cap, which is
454/// right for a preview and wrong for handing the issue to someone as a
455/// specification: an issue longer than the cap loses its tail silently. This
456/// returns the whole range, so what comes back is what the file holds.
457///
458/// The secret screen stays: a heading that carries credential-shaped text is
459/// refused here exactly as it is in a preview.
460///
461/// # Errors
462///
463/// Returns an error if the heading's file cannot be read, or the heading
464/// looks like secret material.
465pub fn org_text_from(rec: &IssueRec) -> Result<String> {
466    let content = fs::read_to_string(&rec.path)?;
467    let lines: Vec<&str> = content.lines().collect();
468    let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
469    let to = rec.heading.line_end.min(lines.len()).max(from);
470    let text = lines[from..to].join("\n");
471    if let Some(marker) = secret_marker(&text) {
472        return Err(Error::Other(anyhow::anyhow!(
473            "{} looks like secret material; open {} directly",
474            marker,
475            rec.path.display()
476        )));
477    }
478    Ok(text)
479}
480
481/// Text shape of [`crate::agent::body_excerpt`].
482pub(crate) fn format_body_excerpt(excerpt: &Excerpt) -> String {
483    if excerpt.suppressed {
484        return excerpt.text.clone();
485    }
486    let from = excerpt.line_start.saturating_sub(1);
487    let to = excerpt.line_end.min(from + BODY_EXCERPT_MAX_LINES);
488    format!(
489        "id: {}\nfile: {}:{}-{}\n--- excerpt (lines {}-{}) ---\n{}\n",
490        excerpt.id,
491        excerpt.file,
492        excerpt.line_start,
493        excerpt.line_end,
494        from + 1,
495        to,
496        excerpt.text
497    )
498}
499
500/// The marker that makes an excerpt look like it carries a credential.
501///
502/// A guard against handing an agent a secret by accident, not a redaction
503/// guarantee: it screens the shapes credentials are usually written in, and
504/// SECURITY.md says plainly that the answer is to keep them out of issue
505/// bodies. Widening it is cheap; relying on it is not.
506pub(crate) fn secret_marker(excerpt: &str) -> Option<&'static str> {
507    let lower = excerpt.to_lowercase();
508    // PEM and OpenSSH private key blocks, whatever the algorithm.
509    if lower.contains("-----begin") && lower.contains("private key") {
510        return Some("a private key block");
511    }
512    for token in [
513        "private_key",
514        "secret_key",
515        "client_secret",
516        "access_token",
517        "refresh_token",
518        "bearer ",
519        "authorization:",
520        "aws_secret_access_key",
521        "begin rsa",
522        "begin openssh",
523        "begin pgp private",
524    ] {
525        if lower.contains(token) {
526            return Some("a credential keyword");
527        }
528    }
529    // `key = value` shapes: an assignment whose name reads like a credential
530    // and whose value holds no space, which prose after a colon usually does.
531    // Judged on the name, not on how random the value looks: a guard should
532    // suppress a placeholder in an `api_key =` line rather than reason about
533    // whether this particular one is live.
534    for line in lower.lines() {
535        let Some((name, value)) = line.split_once(['=', ':']) else {
536            continue;
537        };
538        let name = name
539            .trim()
540            .trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
541        let value = value.trim().trim_matches(['"', '\'']);
542        if value.len() < 12 || value.contains(char::is_whitespace) {
543            continue;
544        }
545        if ["password", "passwd", "api_key", "apikey", "token", "secret"]
546            .iter()
547            .any(|needle| name.ends_with(needle))
548        {
549            return Some("an assignment to a credential name");
550        }
551    }
552    // Token prefixes, matched on a whole word and in the case they are
553    // issued in. A substring test here is what turns "making" into a cloud
554    // key and "task-force" into an API one.
555    for word in excerpt.split(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
556        let word = word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-');
557        if word.len() < 12 {
558            continue;
559        }
560        for prefix in [
561            "ghp_",
562            "gho_",
563            "ghs_",
564            "github_pat_",
565            "xoxb-",
566            "xoxp-",
567            "xoxa-",
568            "xoxs-",
569            "sk-",
570            "AKIA",
571            "ASIA",
572            "glpat-",
573        ] {
574            if word.starts_with(prefix) {
575                return Some("a vendor token prefix");
576            }
577        }
578    }
579    None
580}
581
582/// Case-insensitive substring scan over id, title, properties, and body.
583///
584/// # Errors
585///
586/// Does not fail for a parsed catalog.
587pub fn search_hits_from(issues: &[IssueRec], query: &str, limit: usize) -> Result<Vec<SearchHit>> {
588    let needle = query.to_lowercase();
589    let mut hits: Vec<(char, String, String, SearchHit)> = Vec::new();
590    for rec in issues {
591        let h = &rec.heading;
592        if !search_haystack(rec).to_lowercase().contains(&needle)
593            && !rec.tag_settings.matches_query(&h.tags(), &needle)
594        {
595            continue;
596        }
597        hits.push((
598            h.priority,
599            h.state.clone(),
600            h.id.clone(),
601            SearchHit {
602                id: h.id.clone(),
603                project: rec.project.clone(),
604                state: h.state.clone(),
605                priority: h.priority.to_string(),
606                title: h.title.clone(),
607                snippet: search_snippet(rec, &needle),
608            },
609        ));
610    }
611    hits.sort_by(|a, b| {
612        a.0.cmp(&b.0)
613            .then_with(|| a.1.cmp(&b.1))
614            .then_with(|| a.2.cmp(&b.2))
615    });
616    hits.truncate(limit);
617    Ok(hits.into_iter().map(|h| h.3).collect())
618}
619
620fn search_haystack(rec: &IssueRec) -> String {
621    let h = &rec.heading;
622    let mut hay = String::new();
623    hay.push_str(&h.id);
624    hay.push(' ');
625    hay.push_str(&h.title);
626    hay.push(' ');
627    for (k, v) in &h.properties {
628        hay.push_str(k);
629        hay.push(':');
630        hay.push_str(v);
631        hay.push(' ');
632    }
633    for tag in rec.tag_settings.all_tags(&h.tags()) {
634        hay.push_str(&tag);
635        hay.push(' ');
636    }
637    hay.push_str(&h.body);
638    hay
639}
640
641fn search_snippet(rec: &IssueRec, needle: &str) -> String {
642    let h = &rec.heading;
643    let mut candidates = vec![h.id.clone(), h.title.clone()];
644    for (k, v) in &h.properties {
645        candidates.push(format!("{k}:{v}"));
646    }
647    candidates.extend(rec.tag_settings.all_tags(&h.tags()));
648    candidates.extend(h.body.lines().map(str::to_string));
649    let found = candidates
650        .into_iter()
651        .find(|line| line.to_lowercase().contains(needle))
652        .unwrap_or_else(|| h.title.clone());
653    const CAP: usize = 160;
654    if found.chars().count() > CAP {
655        let mut cut: String = found.chars().take(CAP).collect();
656        cut.push_str("...");
657        cut
658    } else {
659        found
660    }
661}
662
663/// Live claims, oldest first, optionally narrowed by holder or project.
664///
665/// # Errors
666///
667/// Does not fail for a parsed catalog.
668pub fn claims_from(
669    issues: &[IssueRec],
670    holder: Option<&str>,
671    project: Option<&str>,
672) -> Result<Vec<ClaimRow>> {
673    let today = Local::now().date_naive();
674    let mut rows: Vec<(String, ClaimRow)> = Vec::new();
675    for rec in issues {
676        if !project_selected(&rec.project, project) {
677            continue;
678        }
679        let Some(who) = rec.heading.claimed_by() else {
680            continue;
681        };
682        if let Some(filter) = holder
683            && who != filter
684        {
685            continue;
686        }
687        let age = rec
688            .heading
689            .claimed_at()
690            .and_then(parse_org_date)
691            .map(|d| (today - d).num_days())
692            .unwrap_or(-1);
693        rows.push((
694            rec.heading.claimed_at().unwrap_or("").to_string(),
695            ClaimRow {
696                id: rec.heading.id.clone(),
697                project: rec.project.clone(),
698                state: rec.heading.state.clone(),
699                priority: rec.heading.priority.to_string(),
700                holder: Some(who.to_string()),
701                claimed_at: rec.heading.claimed_at().map(str::to_string),
702                age_days: age,
703                title: rec.heading.title.clone(),
704            },
705        ));
706    }
707    rows.sort_by(|a, b| a.0.cmp(&b.0));
708    Ok(rows.into_iter().map(|r| r.1).collect())
709}
710
711/// Dated open work in the next `days` days, plus anything already overdue.
712///
713/// # Errors
714///
715/// Does not fail for a parsed catalog.
716pub fn agenda_rows_from(
717    issues: &[IssueRec],
718    days: i64,
719    project: Option<&str>,
720) -> Result<Vec<AgendaRow>> {
721    let today = Local::now().date_naive();
722    let horizon = today + chrono::Duration::days(days);
723    let mut rows: Vec<(chrono::NaiveDate, char, AgendaRow)> = Vec::new();
724    for rec in issues {
725        if !project_selected(&rec.project, project) {
726            continue;
727        }
728        let h = &rec.heading;
729        if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
730            continue;
731        }
732        for (kind_ch, kind, value) in [
733            ('D', "deadline", h.deadline()),
734            ('S', "scheduled", h.scheduled()),
735        ] {
736            let Some(parsed) = value.and_then(parse_org_date) else {
737                continue;
738            };
739            if parsed > horizon {
740                continue;
741            }
742            let delta = (parsed - today).num_days();
743            rows.push((
744                parsed,
745                kind_ch,
746                AgendaRow {
747                    date: parsed.to_string(),
748                    kind: kind.to_string(),
749                    overdue_days: if delta < 0 { -delta } else { 0 },
750                    id: h.id.clone(),
751                    project: rec.project.clone(),
752                    state: h.state.clone(),
753                    priority: h.priority.to_string(),
754                    title: h.title.clone(),
755                },
756            ));
757        }
758    }
759    rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.id.cmp(&b.2.id)));
760    Ok(rows.into_iter().map(|r| r.2).collect())
761}
762
763/// Parent/child subtree rooted at `id`.
764///
765/// # Errors
766///
767/// Returns an error if `id` is not in the catalog.
768pub fn tree_from(issues: &[IssueRec], id: &str) -> Result<TreeNode> {
769    if !issues.iter().any(|r| r.heading.id == id) {
770        return Err(Error::IssueNotFound { id: id.to_string() });
771    }
772    let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
773    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
774    for rec in issues {
775        by_id.insert(rec.heading.id.as_str(), &rec.heading);
776        if let Some(parent) = rec.heading.parent() {
777            children
778                .entry(parent)
779                .or_default()
780                .push(rec.heading.id.as_str());
781        }
782    }
783    for kids in children.values_mut() {
784        kids.sort_unstable();
785    }
786    Ok(build_tree(id, &by_id, &children, &mut HashSet::new()))
787}
788
789fn build_tree<'a>(
790    id: &'a str,
791    by_id: &HashMap<&'a str, &'a IssueHeading>,
792    children: &HashMap<&'a str, Vec<&'a str>>,
793    seen: &mut HashSet<&'a str>,
794) -> TreeNode {
795    if !seen.insert(id) {
796        return TreeNode {
797            id: id.to_string(),
798            state: String::new(),
799            title: String::new(),
800            children: Vec::new(),
801            blocked_by: Vec::new(),
802        };
803    }
804    let Some(h) = by_id.get(id) else {
805        return TreeNode {
806            id: id.to_string(),
807            state: String::new(),
808            title: String::new(),
809            children: Vec::new(),
810            blocked_by: Vec::new(),
811        };
812    };
813    let kids = children
814        .get(id)
815        .into_iter()
816        .flatten()
817        .map(|kid| build_tree(kid, by_id, children, seen))
818        .collect();
819    TreeNode {
820        id: h.id.clone(),
821        state: h.state.clone(),
822        title: h.title.clone(),
823        children: kids,
824        blocked_by: h.blocked_by(),
825    }
826}
827
828/// Issues whose `:PARENT:` points at `parent_id`.
829///
830/// # Errors
831///
832/// Returns an error if `parent_id` is not in the catalog and no children exist.
833pub fn children_from(issues: &[IssueRec], parent_id: &str) -> Result<Vec<WalkHit>> {
834    let mut rows: Vec<(char, String, String, WalkHit)> = Vec::new();
835    for rec in issues {
836        if rec.heading.parent() == Some(parent_id) {
837            rows.push((
838                rec.heading.priority,
839                rec.heading.state.clone(),
840                rec.heading.id.clone(),
841                walk_hit(rec, "child"),
842            ));
843        }
844    }
845    if rows.is_empty() && !known_issue_id(issues, parent_id) {
846        return Err(Error::IssueNotFound {
847            id: parent_id.to_string(),
848        });
849    }
850    rows.sort_by(|a, b| {
851        a.0.cmp(&b.0)
852            .then_with(|| a.1.cmp(&b.1))
853            .then_with(|| a.2.cmp(&b.2))
854    });
855    Ok(rows.into_iter().map(|r| r.3).collect())
856}
857
858enum WalkKind {
859    Ancestors,
860    Impact,
861}
862
863fn walk_from(issues: &[IssueRec], id: &str, depth: usize, kind: WalkKind) -> Result<Vec<WalkHit>> {
864    let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
865    let walked = match kind {
866        WalkKind::Ancestors => graph.ancestors(id, depth)?,
867        WalkKind::Impact => graph.descendants(id, depth)?,
868    };
869    let relation = match kind {
870        WalkKind::Ancestors => "ancestor",
871        WalkKind::Impact => "descendant",
872    };
873    Ok(walked
874        .into_iter()
875        .filter_map(|(_distance, other)| {
876            issues
877                .iter()
878                .find(|r| r.heading.id == other)
879                .map(|r| walk_hit(r, relation))
880        })
881        .collect())
882}
883
884/// The working set for `id`: its plan, its declared inputs and their products,
885/// and what it has produced so far.
886///
887/// Retrieval is the wrong shape for this question. An agent about to work a node
888/// does not need what a scorer thinks resembles it; it needs what the plan says
889/// the node stands on, which the corpus already states as `:PARENT:`,
890/// `:BLOCKED_BY:`, and `:DISCOVERED_FROM:`. Walking those edges answers exactly,
891/// with no index to build, no embedding to drift, and no threshold to tune.
892///
893/// `depth` bounds the blocker walk and defaults to one hop at every caller,
894/// because a deed carries its own `sources` and `deedar trail` walks them. Going
895/// deeper here would re-derive, less well, a graph the deed store already holds.
896///
897/// # Errors
898///
899/// Returns an error if `id` is not in the catalog, or the blocker graph cannot
900/// be built.
901pub fn recall_from(issues: &[IssueRec], id: &str, depth: usize, excerpts: bool) -> Result<Recall> {
902    let rec = issues
903        .iter()
904        .find(|r| r.heading.id == id)
905        .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
906
907    let mut plan: Vec<WalkHit> = Vec::new();
908    let mut seen: HashSet<&str> = HashSet::from([id]);
909    let mut at = rec.heading.parent();
910    // A hand-edited `:PARENT:` can point back down at a descendant, and `check`
911    // reports that rather than preventing it. Following it here would hang the
912    // command that was supposed to explain the issue.
913    while let Some(parent) = at {
914        if !seen.insert(parent) {
915            break;
916        }
917        match issues.iter().find(|r| r.heading.id == parent) {
918            Some(prec) => {
919                plan.push(walk_hit(prec, "plan"));
920                at = prec.heading.parent();
921            }
922            None => {
923                // A `:PARENT:` may name any Org heading with an `:ID:` under
924                // the prefix, so a design document can head a work hierarchy.
925                // This catalog holds issues and not that document, and the
926                // document is exactly what a reader should open, so it is
927                // named rather than dropped.
928                plan.push(WalkHit {
929                    id: parent.to_string(),
930                    project: String::new(),
931                    state: String::new(),
932                    title: "(a heading outside the tracker)".to_string(),
933                    relation: "plan".to_string(),
934                });
935                break;
936            }
937        }
938    }
939    plan.reverse();
940
941    let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
942    let mut walked = graph.ancestors(id, depth)?;
943    // Furthest first: that is the order the work happened, so the products read
944    // in the order they were made.
945    walked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
946    let mut inputs: Vec<RecallInput> = Vec::new();
947    for (distance, other) in walked {
948        let Some(orec) = issues.iter().find(|r| r.heading.id == other) else {
949            continue;
950        };
951        let relation = if distance == 1 {
952            "blocked-by".to_string()
953        } else {
954            format!("blocked-by:{distance}")
955        };
956        inputs.push(recall_input(orec, &relation, excerpts));
957    }
958    // Where the work came from is an input a blocker edge does not carry: a
959    // bounce names its origin and nothing else points back at it.
960    if let Some(origin) = crate::props::get(&rec.heading.properties, crate::props::DISCOVERED_FROM)
961        && !inputs.iter().any(|i| i.id == origin)
962        && let Some(orec) = issues.iter().find(|r| r.heading.id == origin)
963    {
964        inputs.push(recall_input(orec, "discovered-from", excerpts));
965    }
966
967    Ok(Recall {
968        id: rec.heading.id.clone(),
969        project: rec.project.clone(),
970        state: rec.heading.state.clone(),
971        title: rec.heading.title.clone(),
972        plan,
973        inputs,
974        produced: rec.heading.deeds(),
975        body: rec.heading.body.trim_end().to_string(),
976    })
977}
978
979fn recall_input(rec: &IssueRec, relation: &str, excerpts: bool) -> RecallInput {
980    RecallInput {
981        id: rec.heading.id.clone(),
982        project: rec.project.clone(),
983        state: rec.heading.state.clone(),
984        title: rec.heading.title.clone(),
985        relation: relation.to_string(),
986        deeds: rec.heading.deeds(),
987        // Through the excerpt path rather than the raw body, so the cap and the
988        // credential screening that `body-excerpt` applies are applied here too.
989        // A failure to read the file is not worth failing the whole working set
990        // over: the rest of the answer is still correct.
991        excerpt: excerpts
992            .then(|| excerpt_from(rec).ok().map(|e| e.text))
993            .flatten(),
994        // Newest first, so the first note in the drawer is the last thing that
995        // was said about the issue. The tracker's own claim-release line is not
996        // one of those, and it is the newest note on almost every closed issue.
997        last_note: rec
998            .heading
999            .logbook
1000            .iter()
1001            .filter(|entry| !entry.is_bookkeeping())
1002            .find_map(|entry| entry.note.clone()),
1003    }
1004}
1005
1006/// Issues that refer to `target_id` through an edge, a parent, a
1007/// discovered-from or pivoted-to property, or a body mention.
1008///
1009/// A deed accession is answered too, and there the relation is `cites`: the
1010/// issues carrying it in `:DEEDS:`, plus any that name it only in prose. The
1011/// corpus decides which of the two namespaces the target is in, so an issue id
1012/// that happens to look like an accession keeps its own meaning.
1013///
1014/// # Errors
1015///
1016/// Returns an error if `target_id` is neither a known issue id nor an
1017/// accession, and no backlinks exist.
1018pub fn backlinks_from(issues: &[IssueRec], target_id: &str) -> Result<Vec<WalkHit>> {
1019    let mut out = Vec::new();
1020    if !known_issue_id(issues, target_id) && crate::ops::is_deed_accession(target_id) {
1021        for rec in issues {
1022            if rec.heading.deeds().iter().any(|cited| cited == target_id) {
1023                out.push(walk_hit(rec, "cites"));
1024            } else if rec.heading.body.contains(target_id) {
1025                out.push(walk_hit(rec, "body mention"));
1026            }
1027        }
1028        // A deed nobody cited is an empty answer rather than an error. The
1029        // product may be real and simply unused, which is a fact about the
1030        // tracker and not a bad argument.
1031        return Ok(out);
1032    }
1033    for rec in issues {
1034        if rec.heading.id == target_id {
1035            continue;
1036        }
1037        let mut hit = false;
1038        if rec.heading.blocked_by().iter().any(|b| b == target_id) {
1039            out.push(walk_hit(rec, "blocked-by"));
1040            hit = true;
1041        }
1042        if rec.heading.parent() == Some(target_id) {
1043            out.push(walk_hit(rec, "parent"));
1044            hit = true;
1045        }
1046        if rec
1047            .heading
1048            .properties
1049            .get("DISCOVERED_FROM")
1050            .map(String::as_str)
1051            == Some(target_id)
1052        {
1053            out.push(walk_hit(rec, "discovered-from"));
1054            hit = true;
1055        }
1056        if rec.heading.properties.get("PIVOTED_TO").map(String::as_str) == Some(target_id) {
1057            out.push(walk_hit(rec, "pivoted-to"));
1058            hit = true;
1059        }
1060        if !hit && rec.heading.body.contains(target_id) {
1061            out.push(walk_hit(rec, "body mention"));
1062        }
1063    }
1064    if out.is_empty() && !known_issue_id(issues, target_id) {
1065        return Err(Error::IssueNotFound {
1066            id: target_id.to_string(),
1067        });
1068    }
1069    Ok(out)
1070}
1071
1072/// Children and blockers below `id` as indented text or Graphviz DOT.
1073///
1074/// # Errors
1075///
1076/// Returns an error if `id` is not in the catalog, or `format` is not
1077/// `ascii`, `text`, or `dot`.
1078pub fn tree_text_from(issues: &[IssueRec], id: &str, format: &str) -> Result<String> {
1079    if !issues.iter().any(|r| r.heading.id == id) {
1080        return Err(Error::IssueNotFound { id: id.to_string() });
1081    }
1082    let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
1083    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1084    let mut blockers: HashMap<&str, Vec<String>> = HashMap::new();
1085    for rec in issues {
1086        by_id.insert(rec.heading.id.as_str(), &rec.heading);
1087        if let Some(parent) = rec.heading.parent() {
1088            children
1089                .entry(parent)
1090                .or_default()
1091                .push(rec.heading.id.as_str());
1092        }
1093        let blocked = rec.heading.blocked_by();
1094        if !blocked.is_empty() {
1095            blockers.insert(rec.heading.id.as_str(), blocked);
1096        }
1097    }
1098    for kids in children.values_mut() {
1099        kids.sort_unstable();
1100    }
1101    let mut out = String::new();
1102    match format {
1103        "ascii" | "text" => tree_ascii_from(
1104            id,
1105            0,
1106            &by_id,
1107            &children,
1108            &blockers,
1109            &mut HashSet::new(),
1110            &mut out,
1111        ),
1112        "dot" => tree_dot_from(id, &by_id, &children, &blockers, &mut out),
1113        other => {
1114            return Err(Error::Other(anyhow::anyhow!(
1115                "unknown format {other:?}; allowed: ascii, dot"
1116            )));
1117        }
1118    }
1119    Ok(out)
1120}
1121
1122fn tree_ascii_from<'a>(
1123    id: &'a str,
1124    depth: usize,
1125    by_id: &HashMap<&str, &IssueHeading>,
1126    children: &HashMap<&str, Vec<&'a str>>,
1127    blockers: &'a HashMap<&str, Vec<String>>,
1128    seen: &mut HashSet<&'a str>,
1129    out: &mut String,
1130) {
1131    use std::fmt::Write as _;
1132    if !seen.insert(id) {
1133        let _ = writeln!(out, "{}{id} (cycle, stopping)", "  ".repeat(depth));
1134        return;
1135    }
1136    let Some(h) = by_id.get(id) else {
1137        let _ = writeln!(out, "{}{id} (missing)", "  ".repeat(depth));
1138        return;
1139    };
1140    let _ = writeln!(
1141        out,
1142        "{}{id} {:<9} [#{}]  {}",
1143        "  ".repeat(depth),
1144        h.state,
1145        h.priority,
1146        h.title
1147    );
1148    if let Some(blocked) = blockers.get(id) {
1149        for blocker in blocked {
1150            let _ = writeln!(out, "{}* blocked-by {blocker}", "  ".repeat(depth + 1));
1151        }
1152    }
1153    if let Some(kids) = children.get(id) {
1154        for kid in kids {
1155            tree_ascii_from(kid, depth + 1, by_id, children, blockers, seen, out);
1156        }
1157    }
1158}
1159
1160fn tree_dot_from<'a>(
1161    root_id: &'a str,
1162    by_id: &HashMap<&str, &IssueHeading>,
1163    children: &HashMap<&str, Vec<&'a str>>,
1164    blockers: &'a HashMap<&str, Vec<String>>,
1165    out: &mut String,
1166) {
1167    use std::fmt::Write as _;
1168    let _ = writeln!(out, "digraph vissue_tree {{");
1169    let _ = writeln!(out, "  rankdir=LR;");
1170    let _ = writeln!(
1171        out,
1172        "  node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1173    );
1174    let mut visited: HashSet<&str> = HashSet::new();
1175    let mut stack = vec![root_id];
1176    while let Some(id) = stack.pop() {
1177        if !visited.insert(id) {
1178            continue;
1179        }
1180        if let Some(h) = by_id.get(id) {
1181            let _ = writeln!(
1182                out,
1183                "  \"{}\" [label=\"{}\\n{} [#{}]\"];",
1184                dot_quoted(&h.id),
1185                dot_quoted(&h.title),
1186                dot_quoted(&h.state),
1187                dot_quoted(&h.priority.to_string())
1188            );
1189            if let Some(kids) = children.get(id) {
1190                for kid in kids {
1191                    let _ = writeln!(
1192                        out,
1193                        "  \"{}\" -> \"{}\" [color=\"#00897B\"];",
1194                        dot_quoted(&h.id),
1195                        dot_quoted(kid)
1196                    );
1197                    stack.push(kid);
1198                }
1199            }
1200            if let Some(blocked) = blockers.get(id) {
1201                for b in blocked {
1202                    let _ = writeln!(
1203                        out,
1204                        "  \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1205                        dot_quoted(b),
1206                        dot_quoted(&h.id)
1207                    );
1208                    stack.push(b.as_str());
1209                }
1210            }
1211        }
1212    }
1213    let _ = writeln!(out, "}}");
1214}
1215
1216fn dot_quoted(text: &str) -> String {
1217    text.replace('\\', "\\\\")
1218        .replace('"', "\\\"")
1219        .replace('\n', "\\n")
1220        .replace('\r', "")
1221}
1222
1223fn known_issue_id(issues: &[IssueRec], id: &str) -> bool {
1224    issues.iter().any(|r| r.heading.id == id)
1225}
1226
1227fn walk_hit(rec: &IssueRec, relation: &str) -> WalkHit {
1228    WalkHit {
1229        id: rec.heading.id.clone(),
1230        project: rec.project.clone(),
1231        state: rec.heading.state.clone(),
1232        title: rec.heading.title.clone(),
1233        relation: relation.to_string(),
1234    }
1235}