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