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/// Three Org classes, in this order: `deadline` (overdue first),
714/// `scheduled` (eligible that day and after), `appointment` (a plain
715/// active stamp in the title, that day only).
716///
717/// # Errors
718///
719/// Does not fail for a parsed catalog.
720pub fn agenda_rows_from(
721    issues: &[IssueRec],
722    days: i64,
723    project: Option<&str>,
724) -> Result<Vec<AgendaRow>> {
725    let today = Local::now().date_naive();
726    let horizon = today + chrono::Duration::days(days);
727    let mut rows: Vec<AgendaRow> = Vec::new();
728    for rec in issues {
729        if !project_selected(&rec.project, project) {
730            continue;
731        }
732        let h = &rec.heading;
733        if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
734            continue;
735        }
736        let deadline = h.deadline().and_then(parse_org_date);
737        let scheduled = h.scheduled().and_then(parse_org_date);
738        for (kind, parsed) in [("deadline", deadline), ("scheduled", scheduled)] {
739            let Some(parsed) = parsed else {
740                continue;
741            };
742            if parsed > horizon {
743                continue;
744            }
745            let delta = (parsed - today).num_days();
746            rows.push(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        for parsed in active_stamps_in(&h.title) {
758            if deadline == Some(parsed) || scheduled == Some(parsed) {
759                continue;
760            }
761            if parsed < today || parsed > horizon {
762                continue;
763            }
764            rows.push(AgendaRow {
765                date: parsed.to_string(),
766                kind: "appointment".to_string(),
767                overdue_days: 0,
768                id: h.id.clone(),
769                project: rec.project.clone(),
770                state: h.state.clone(),
771                priority: h.priority.to_string(),
772                title: h.title.clone(),
773            });
774        }
775    }
776    rows.sort_by(|a, b| {
777        agenda_kind_rank(&a.kind)
778            .cmp(&agenda_kind_rank(&b.kind))
779            .then(b.overdue_days.cmp(&a.overdue_days))
780            .then(a.date.cmp(&b.date))
781            .then(a.id.cmp(&b.id))
782    });
783    Ok(rows)
784}
785
786fn agenda_kind_rank(kind: &str) -> u8 {
787    match kind {
788        "deadline" => 0,
789        "scheduled" => 1,
790        _ => 2,
791    }
792}
793
794/// Active Org stamps in `text` (`<YYYY-MM-DD...>`). Inactive `[...]` stay out.
795fn active_stamps_in(text: &str) -> Vec<chrono::NaiveDate> {
796    let mut out = Vec::new();
797    let bytes = text.as_bytes();
798    let mut i = 0;
799    while i + 11 <= bytes.len() {
800        if bytes[i] == b'<'
801            && let Ok(slice) = std::str::from_utf8(&bytes[i + 1..i + 11])
802            && let Ok(date) = chrono::NaiveDate::parse_from_str(slice, "%Y-%m-%d")
803        {
804            out.push(date);
805            i += 11;
806            continue;
807        }
808        i += 1;
809    }
810    out
811}
812
813/// Parent/child subtree rooted at `id`.
814///
815/// # Errors
816///
817/// Returns an error if `id` is not in the catalog.
818pub fn tree_from(issues: &[IssueRec], id: &str) -> Result<TreeNode> {
819    if !issues.iter().any(|r| r.heading.id == id) {
820        return Err(Error::IssueNotFound { id: id.to_string() });
821    }
822    let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
823    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
824    for rec in issues {
825        by_id.insert(rec.heading.id.as_str(), &rec.heading);
826        if let Some(parent) = rec.heading.parent() {
827            children
828                .entry(parent)
829                .or_default()
830                .push(rec.heading.id.as_str());
831        }
832    }
833    for kids in children.values_mut() {
834        kids.sort_unstable();
835    }
836    Ok(build_tree(id, &by_id, &children, &mut HashSet::new()))
837}
838
839fn build_tree<'a>(
840    id: &'a str,
841    by_id: &HashMap<&'a str, &'a IssueHeading>,
842    children: &HashMap<&'a str, Vec<&'a str>>,
843    seen: &mut HashSet<&'a str>,
844) -> TreeNode {
845    if !seen.insert(id) {
846        return TreeNode {
847            id: id.to_string(),
848            state: String::new(),
849            title: String::new(),
850            children: Vec::new(),
851            blocked_by: Vec::new(),
852        };
853    }
854    let Some(h) = by_id.get(id) else {
855        return TreeNode {
856            id: id.to_string(),
857            state: String::new(),
858            title: String::new(),
859            children: Vec::new(),
860            blocked_by: Vec::new(),
861        };
862    };
863    let kids = children
864        .get(id)
865        .into_iter()
866        .flatten()
867        .map(|kid| build_tree(kid, by_id, children, seen))
868        .collect();
869    TreeNode {
870        id: h.id.clone(),
871        state: h.state.clone(),
872        title: h.title.clone(),
873        children: kids,
874        blocked_by: h.blocked_by(),
875    }
876}
877
878/// Issues whose `:PARENT:` points at `parent_id`.
879///
880/// # Errors
881///
882/// Returns an error if `parent_id` is not in the catalog and no children exist.
883pub fn children_from(issues: &[IssueRec], parent_id: &str) -> Result<Vec<WalkHit>> {
884    let mut rows: Vec<(char, String, String, WalkHit)> = Vec::new();
885    for rec in issues {
886        if rec.heading.parent() == Some(parent_id) {
887            rows.push((
888                rec.heading.priority,
889                rec.heading.state.clone(),
890                rec.heading.id.clone(),
891                walk_hit(rec, "child"),
892            ));
893        }
894    }
895    if rows.is_empty() && !known_issue_id(issues, parent_id) {
896        return Err(Error::IssueNotFound {
897            id: parent_id.to_string(),
898        });
899    }
900    rows.sort_by(|a, b| {
901        a.0.cmp(&b.0)
902            .then_with(|| a.1.cmp(&b.1))
903            .then_with(|| a.2.cmp(&b.2))
904    });
905    Ok(rows.into_iter().map(|r| r.3).collect())
906}
907
908enum WalkKind {
909    Ancestors,
910    Impact,
911}
912
913fn walk_from(issues: &[IssueRec], id: &str, depth: usize, kind: WalkKind) -> Result<Vec<WalkHit>> {
914    let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
915    let walked = match kind {
916        WalkKind::Ancestors => graph.ancestors(id, depth)?,
917        WalkKind::Impact => graph.descendants(id, depth)?,
918    };
919    let relation = match kind {
920        WalkKind::Ancestors => "ancestor",
921        WalkKind::Impact => "descendant",
922    };
923    Ok(walked
924        .into_iter()
925        .filter_map(|(_distance, other)| {
926            issues
927                .iter()
928                .find(|r| r.heading.id == other)
929                .map(|r| walk_hit(r, relation))
930        })
931        .collect())
932}
933
934/// The working set for `id`: its plan, its declared inputs and their products,
935/// and what it has produced so far.
936///
937/// Retrieval is the wrong shape for this question. An agent about to work a node
938/// does not need what a scorer thinks resembles it; it needs what the plan says
939/// the node stands on, which the corpus already states as `:PARENT:`,
940/// `:BLOCKED_BY:`, and `:DISCOVERED_FROM:`. Walking those edges answers exactly,
941/// with no index to build, no embedding to drift, and no threshold to tune.
942///
943/// `depth` bounds the blocker walk and defaults to one hop at every caller,
944/// because a deed carries its own `sources` and `deedar trail` walks them. Going
945/// deeper here would re-derive, less well, a graph the deed store already holds.
946///
947/// # Errors
948///
949/// Returns an error if `id` is not in the catalog, or the blocker graph cannot
950/// be built.
951pub fn recall_from(issues: &[IssueRec], id: &str, depth: usize, excerpts: bool) -> Result<Recall> {
952    let rec = issues
953        .iter()
954        .find(|r| r.heading.id == id)
955        .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
956
957    let mut plan: Vec<WalkHit> = Vec::new();
958    let mut seen: HashSet<&str> = HashSet::from([id]);
959    let mut at = rec.heading.parent();
960    // A hand-edited `:PARENT:` can point back down at a descendant, and `check`
961    // reports that rather than preventing it. Following it here would hang the
962    // command that was supposed to explain the issue.
963    while let Some(parent) = at {
964        if !seen.insert(parent) {
965            break;
966        }
967        match issues.iter().find(|r| r.heading.id == parent) {
968            Some(prec) => {
969                plan.push(walk_hit(prec, "plan"));
970                at = prec.heading.parent();
971            }
972            None => {
973                // A `:PARENT:` may name any Org heading with an `:ID:` under
974                // the prefix, so a design document can head a work hierarchy.
975                // This catalog holds issues and not that document, and the
976                // document is exactly what a reader should open, so it is
977                // named rather than dropped.
978                plan.push(WalkHit {
979                    id: parent.to_string(),
980                    project: String::new(),
981                    state: String::new(),
982                    title: "(a heading outside the tracker)".to_string(),
983                    relation: "plan".to_string(),
984                });
985                break;
986            }
987        }
988    }
989    plan.reverse();
990
991    let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
992    let mut walked = graph.ancestors(id, depth)?;
993    // Furthest first: that is the order the work happened, so the products read
994    // in the order they were made.
995    walked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
996    let mut inputs: Vec<RecallInput> = Vec::new();
997    for (distance, other) in walked {
998        let Some(orec) = issues.iter().find(|r| r.heading.id == other) else {
999            continue;
1000        };
1001        let relation = if distance == 1 {
1002            "blocked-by".to_string()
1003        } else {
1004            format!("blocked-by:{distance}")
1005        };
1006        inputs.push(recall_input(orec, &relation, excerpts));
1007    }
1008    // Where the work came from is an input a blocker edge does not carry: a
1009    // bounce names its origin and nothing else points back at it.
1010    if let Some(origin) = crate::props::get(&rec.heading.properties, crate::props::DISCOVERED_FROM)
1011        && !inputs.iter().any(|i| i.id == origin)
1012        && let Some(orec) = issues.iter().find(|r| r.heading.id == origin)
1013    {
1014        inputs.push(recall_input(orec, "discovered-from", excerpts));
1015    }
1016
1017    Ok(Recall {
1018        id: rec.heading.id.clone(),
1019        project: rec.project.clone(),
1020        state: rec.heading.state.clone(),
1021        title: rec.heading.title.clone(),
1022        plan,
1023        inputs,
1024        produced: rec.heading.deeds(),
1025        body: rec.heading.body.trim_end().to_string(),
1026    })
1027}
1028
1029fn recall_input(rec: &IssueRec, relation: &str, excerpts: bool) -> RecallInput {
1030    RecallInput {
1031        id: rec.heading.id.clone(),
1032        project: rec.project.clone(),
1033        state: rec.heading.state.clone(),
1034        title: rec.heading.title.clone(),
1035        relation: relation.to_string(),
1036        deeds: rec.heading.deeds(),
1037        // Through the excerpt path rather than the raw body, so the cap and the
1038        // credential screening that `body-excerpt` applies are applied here too.
1039        // A failure to read the file is not worth failing the whole working set
1040        // over: the rest of the answer is still correct.
1041        excerpt: excerpts
1042            .then(|| excerpt_from(rec).ok().map(|e| e.text))
1043            .flatten(),
1044        // Newest first, so the first note in the drawer is the last thing that
1045        // was said about the issue. The tracker's own claim-release line is not
1046        // one of those, and it is the newest note on almost every closed issue.
1047        last_note: rec
1048            .heading
1049            .logbook
1050            .iter()
1051            .filter(|entry| !entry.is_bookkeeping())
1052            .find_map(|entry| entry.note.clone()),
1053    }
1054}
1055
1056/// Issues that refer to `target_id` through an edge, a parent, a
1057/// discovered-from or pivoted-to property, or a body mention.
1058///
1059/// A deed accession is answered too, and there the relation is `cites`: the
1060/// issues carrying it in `:DEEDS:`, plus any that name it only in prose. The
1061/// corpus decides which of the two namespaces the target is in, so an issue id
1062/// that happens to look like an accession keeps its own meaning.
1063///
1064/// # Errors
1065///
1066/// Returns an error if `target_id` is neither a known issue id nor an
1067/// accession, and no backlinks exist.
1068pub fn backlinks_from(issues: &[IssueRec], target_id: &str) -> Result<Vec<WalkHit>> {
1069    let mut out = Vec::new();
1070    if !known_issue_id(issues, target_id) && crate::ops::is_deed_accession(target_id) {
1071        for rec in issues {
1072            if rec.heading.deeds().iter().any(|cited| cited == target_id) {
1073                out.push(walk_hit(rec, "cites"));
1074            } else if rec.heading.body.contains(target_id) {
1075                out.push(walk_hit(rec, "body mention"));
1076            }
1077        }
1078        // A deed nobody cited is an empty answer rather than an error. The
1079        // product may be real and simply unused, which is a fact about the
1080        // tracker and not a bad argument.
1081        return Ok(out);
1082    }
1083    for rec in issues {
1084        if rec.heading.id == target_id {
1085            continue;
1086        }
1087        let mut hit = false;
1088        if rec.heading.blocked_by().iter().any(|b| b == target_id) {
1089            out.push(walk_hit(rec, "blocked-by"));
1090            hit = true;
1091        }
1092        if rec.heading.parent() == Some(target_id) {
1093            out.push(walk_hit(rec, "parent"));
1094            hit = true;
1095        }
1096        if rec
1097            .heading
1098            .properties
1099            .get("DISCOVERED_FROM")
1100            .map(String::as_str)
1101            == Some(target_id)
1102        {
1103            out.push(walk_hit(rec, "discovered-from"));
1104            hit = true;
1105        }
1106        if rec.heading.properties.get("PIVOTED_TO").map(String::as_str) == Some(target_id) {
1107            out.push(walk_hit(rec, "pivoted-to"));
1108            hit = true;
1109        }
1110        if !hit && rec.heading.body.contains(target_id) {
1111            out.push(walk_hit(rec, "body mention"));
1112        }
1113    }
1114    if out.is_empty() && !known_issue_id(issues, target_id) {
1115        return Err(Error::IssueNotFound {
1116            id: target_id.to_string(),
1117        });
1118    }
1119    Ok(out)
1120}
1121
1122/// Children and blockers below `id` as indented text or Graphviz DOT.
1123///
1124/// # Errors
1125///
1126/// Returns an error if `id` is not in the catalog, or `format` is not
1127/// `ascii`, `text`, or `dot`.
1128pub fn tree_text_from(issues: &[IssueRec], id: &str, format: &str) -> Result<String> {
1129    if !issues.iter().any(|r| r.heading.id == id) {
1130        return Err(Error::IssueNotFound { id: id.to_string() });
1131    }
1132    let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
1133    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1134    let mut blockers: HashMap<&str, Vec<String>> = HashMap::new();
1135    for rec in issues {
1136        by_id.insert(rec.heading.id.as_str(), &rec.heading);
1137        if let Some(parent) = rec.heading.parent() {
1138            children
1139                .entry(parent)
1140                .or_default()
1141                .push(rec.heading.id.as_str());
1142        }
1143        let blocked = rec.heading.blocked_by();
1144        if !blocked.is_empty() {
1145            blockers.insert(rec.heading.id.as_str(), blocked);
1146        }
1147    }
1148    for kids in children.values_mut() {
1149        kids.sort_unstable();
1150    }
1151    let mut out = String::new();
1152    match format {
1153        "ascii" | "text" => tree_ascii_from(
1154            id,
1155            0,
1156            &by_id,
1157            &children,
1158            &blockers,
1159            &mut HashSet::new(),
1160            &mut out,
1161        ),
1162        "dot" => tree_dot_from(id, &by_id, &children, &blockers, &mut out),
1163        other => {
1164            return Err(Error::Other(anyhow::anyhow!(
1165                "unknown format {other:?}; allowed: ascii, dot"
1166            )));
1167        }
1168    }
1169    Ok(out)
1170}
1171
1172fn tree_ascii_from<'a>(
1173    id: &'a str,
1174    depth: usize,
1175    by_id: &HashMap<&str, &IssueHeading>,
1176    children: &HashMap<&str, Vec<&'a str>>,
1177    blockers: &'a HashMap<&str, Vec<String>>,
1178    seen: &mut HashSet<&'a str>,
1179    out: &mut String,
1180) {
1181    use std::fmt::Write as _;
1182    if !seen.insert(id) {
1183        let _ = writeln!(out, "{}{id} (cycle, stopping)", "  ".repeat(depth));
1184        return;
1185    }
1186    let Some(h) = by_id.get(id) else {
1187        let _ = writeln!(out, "{}{id} (missing)", "  ".repeat(depth));
1188        return;
1189    };
1190    let _ = writeln!(
1191        out,
1192        "{}{id} {:<9} [#{}]  {}",
1193        "  ".repeat(depth),
1194        h.state,
1195        h.priority,
1196        h.title
1197    );
1198    if let Some(blocked) = blockers.get(id) {
1199        for blocker in blocked {
1200            let _ = writeln!(out, "{}* blocked-by {blocker}", "  ".repeat(depth + 1));
1201        }
1202    }
1203    if let Some(kids) = children.get(id) {
1204        for kid in kids {
1205            tree_ascii_from(kid, depth + 1, by_id, children, blockers, seen, out);
1206        }
1207    }
1208}
1209
1210fn tree_dot_from<'a>(
1211    root_id: &'a str,
1212    by_id: &HashMap<&str, &IssueHeading>,
1213    children: &HashMap<&str, Vec<&'a str>>,
1214    blockers: &'a HashMap<&str, Vec<String>>,
1215    out: &mut String,
1216) {
1217    use std::fmt::Write as _;
1218    let _ = writeln!(out, "digraph vissue_tree {{");
1219    let _ = writeln!(out, "  rankdir=LR;");
1220    let _ = writeln!(
1221        out,
1222        "  node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1223    );
1224    let mut visited: HashSet<&str> = HashSet::new();
1225    let mut stack = vec![root_id];
1226    while let Some(id) = stack.pop() {
1227        if !visited.insert(id) {
1228            continue;
1229        }
1230        if let Some(h) = by_id.get(id) {
1231            let _ = writeln!(
1232                out,
1233                "  \"{}\" [label=\"{}\\n{} [#{}]\"];",
1234                dot_quoted(&h.id),
1235                dot_quoted(&h.title),
1236                dot_quoted(&h.state),
1237                dot_quoted(&h.priority.to_string())
1238            );
1239            if let Some(kids) = children.get(id) {
1240                for kid in kids {
1241                    let _ = writeln!(
1242                        out,
1243                        "  \"{}\" -> \"{}\" [color=\"#00897B\"];",
1244                        dot_quoted(&h.id),
1245                        dot_quoted(kid)
1246                    );
1247                    stack.push(kid);
1248                }
1249            }
1250            if let Some(blocked) = blockers.get(id) {
1251                for b in blocked {
1252                    let _ = writeln!(
1253                        out,
1254                        "  \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1255                        dot_quoted(b),
1256                        dot_quoted(&h.id)
1257                    );
1258                    stack.push(b.as_str());
1259                }
1260            }
1261        }
1262    }
1263    let _ = writeln!(out, "}}");
1264}
1265
1266fn dot_quoted(text: &str) -> String {
1267    text.replace('\\', "\\\\")
1268        .replace('"', "\\\"")
1269        .replace('\n', "\\n")
1270        .replace('\r', "")
1271}
1272
1273fn known_issue_id(issues: &[IssueRec], id: &str) -> bool {
1274    issues.iter().any(|r| r.heading.id == id)
1275}
1276
1277fn walk_hit(rec: &IssueRec, relation: &str) -> WalkHit {
1278    WalkHit {
1279        id: rec.heading.id.clone(),
1280        project: rec.project.clone(),
1281        state: rec.heading.state.clone(),
1282        title: rec.heading.title.clone(),
1283        relation: relation.to_string(),
1284    }
1285}