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, SearchHit, TreeNode,
16    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    let mut recs = Vec::new();
31    for project in list_projects(layout)? {
32        let path = layout.project_issues_path(&project);
33        let doc = IssueDoc::parse_file(&project, &path)?;
34        for heading in doc.headings {
35            recs.push(IssueRec {
36                project: project.clone(),
37                heading,
38                path: path.clone(),
39            });
40        }
41    }
42    Ok(recs)
43}
44
45/// Read-only queries over a cached `&[IssueRec]`.
46#[derive(Debug)]
47pub struct CatalogService<'a> {
48    issues: &'a [IssueRec],
49}
50
51impl<'a> CatalogService<'a> {
52    /// Query over an already-loaded catalog snapshot.
53    pub fn from_recs(issues: &'a [IssueRec]) -> Self {
54        Self { issues }
55    }
56
57    fn rec(&self, id: &str) -> Result<&IssueRec> {
58        self.issues
59            .iter()
60            .find(|r| r.heading.id == id)
61            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })
62    }
63
64    /// List rows matching `q`, same filters and sort as [`issues_rows_from`].
65    ///
66    /// # Errors
67    ///
68    /// Does not fail for a parsed catalog.
69    pub fn issues_rows(&self, q: ListQuery) -> Result<Vec<IssueRow>> {
70        issues_rows_from(self.issues, q)
71    }
72
73    /// Actionable issues: TODO or STARTED with no open blocker.
74    ///
75    /// # Errors
76    ///
77    /// Does not fail for a parsed catalog.
78    pub fn ready(&self, project: Option<&str>) -> Result<Vec<IssueRow>> {
79        issues_rows_from(
80            self.issues,
81            ListQuery {
82                project: project.map(str::to_string),
83                ready: true,
84                ..ListQuery::default()
85            },
86        )
87    }
88
89    /// One issue as a detail card, including body and logbook.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if `id` is not in the catalog.
94    pub fn detail(&self, id: &str) -> Result<IssueDetail> {
95        Ok(issue_detail(self.rec(id)?))
96    }
97
98    /// On-disk heading range, capped and screened for secrets.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if `id` is not in the catalog, or the heading's file
103    /// cannot be read.
104    pub fn excerpt(&self, id: &str) -> Result<Excerpt> {
105        excerpt_from(self.rec(id)?)
106    }
107
108    /// Case-insensitive substring scan over id, title, properties, and body.
109    ///
110    /// # Errors
111    ///
112    /// Does not fail for a parsed catalog.
113    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
114        search_hits_from(self.issues, query, limit)
115    }
116
117    /// Live claims, oldest first, optionally narrowed by holder or project.
118    ///
119    /// # Errors
120    ///
121    /// Does not fail for a parsed catalog.
122    pub fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>> {
123        claims_from(self.issues, holder, project)
124    }
125
126    /// Dated open work in the next `days` days, plus anything already overdue.
127    ///
128    /// # Errors
129    ///
130    /// Does not fail for a parsed catalog.
131    pub fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>> {
132        agenda_rows_from(self.issues, days, project)
133    }
134
135    /// Parent/child subtree rooted at `id`.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if `id` is not in the catalog.
140    pub fn tree(&self, id: &str) -> Result<TreeNode> {
141        tree_from(self.issues, id)
142    }
143
144    /// Ranked related issues for `id`.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if `id` is not in the catalog.
149    pub fn related(
150        &self,
151        id: &str,
152        depth: usize,
153        limit: usize,
154    ) -> Result<Vec<crate::views::RelatedHit>> {
155        related_hits_from(self.issues, id, depth, limit)
156    }
157
158    /// Issues whose `:PARENT:` points at `id`.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error if `id` is not in the catalog and no children exist.
163    pub fn children(&self, id: &str) -> Result<Vec<WalkHit>> {
164        children_from(self.issues, id)
165    }
166
167    /// Transitive blocker ancestors, limited to `depth` hops.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if `id` is not in the catalog, or the blocker graph
172    /// cannot be built.
173    pub fn ancestors(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
174        walk_from(self.issues, id, depth, WalkKind::Ancestors)
175    }
176
177    /// Transitive issues waiting on `id`, limited to `depth` hops.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if `id` is not in the catalog, or the blocker graph
182    /// cannot be built.
183    pub fn impact(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
184        walk_from(self.issues, id, depth, WalkKind::Impact)
185    }
186
187    /// Issues that refer to `id` through an edge, a parent, a discovered-from
188    /// or pivoted-to property, or a body mention.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if `id` is not in the catalog and no backlinks exist.
193    pub fn backlinks(&self, id: &str) -> Result<Vec<WalkHit>> {
194        backlinks_from(self.issues, id)
195    }
196}
197
198/// List/ready rows, same filters and sort as [`crate::agent::issues_json`].
199///
200/// # Errors
201///
202/// Does not fail for a parsed catalog.
203pub fn issues_rows_from(issues: &[IssueRec], q: ListQuery) -> Result<Vec<IssueRow>> {
204    let active_blockers: HashSet<&str> = if q.ready {
205        issues
206            .iter()
207            .filter(|r| r.heading.state != "DONE" && r.heading.state != "CANCELLED")
208            .map(|r| r.heading.id.as_str())
209            .collect()
210    } else {
211        HashSet::new()
212    };
213
214    let mut rows: Vec<(char, String, String, IssueRow)> = Vec::new();
215    for rec in issues {
216        if !project_selected(&rec.project, q.project.as_deref()) {
217            continue;
218        }
219        if let Some(state) = q.state.as_deref()
220            && rec.heading.state != state
221        {
222            continue;
223        }
224        if q.ready {
225            if !READY_STATES.contains(&rec.heading.state.as_str()) {
226                continue;
227            }
228            if rec
229                .heading
230                .blocked_by()
231                .iter()
232                .any(|b| active_blockers.contains(b.as_str()))
233            {
234                continue;
235            }
236        }
237        if let Some(needle) = q.query.as_deref()
238            && !list_query_matches(&rec.heading, needle)
239        {
240            continue;
241        }
242        rows.push((
243            rec.heading.priority,
244            rec.heading.state.clone(),
245            rec.heading.id.clone(),
246            issue_row(rec),
247        ));
248    }
249    rows.sort_by(|a, b| {
250        a.0.cmp(&b.0)
251            .then_with(|| a.1.cmp(&b.1))
252            .then_with(|| a.2.cmp(&b.2))
253    });
254    let mut out: Vec<IssueRow> = rows.into_iter().map(|r| r.3).collect();
255    let offset = q.offset.unwrap_or(0);
256    if offset >= out.len() {
257        out.clear();
258    } else if offset > 0 {
259        out = out.split_off(offset);
260    }
261    if let Some(limit) = q.limit {
262        out.truncate(limit);
263    }
264    Ok(out)
265}
266
267fn list_query_matches(h: &IssueHeading, needle: &str) -> bool {
268    let needle = needle.to_lowercase();
269    if h.id.to_lowercase().contains(&needle) || h.title.to_lowercase().contains(&needle) {
270        return true;
271    }
272    if h.tags()
273        .iter()
274        .any(|tag| tag.to_lowercase().contains(&needle))
275    {
276        return true;
277    }
278    h.properties
279        .iter()
280        .any(|(k, v)| k.to_lowercase().contains(&needle) || v.to_lowercase().contains(&needle))
281}
282
283fn issue_row(rec: &IssueRec) -> IssueRow {
284    IssueRow {
285        id: rec.heading.id.clone(),
286        state: rec.heading.state.clone(),
287        priority: rec.heading.priority.to_string(),
288        title: rec.heading.title.clone(),
289        project: rec.project.clone(),
290        blocked_by: rec.heading.blocked_by(),
291        claimed_by: rec.heading.claimed_by().map(str::to_string),
292        claimed_at: rec.heading.claimed_at().map(str::to_string),
293        parent: rec.heading.parent().map(str::to_string),
294    }
295}
296
297fn issue_detail(rec: &IssueRec) -> IssueDetail {
298    IssueDetail {
299        id: rec.heading.id.clone(),
300        project: rec.project.clone(),
301        title: rec.heading.title.clone(),
302        state: rec.heading.state.clone(),
303        priority: rec.heading.priority.to_string(),
304        properties: rec.heading.properties.clone(),
305        org_tags: rec.heading.org_tags.clone(),
306        tags: rec.heading.tags(),
307        blocked_by: rec.heading.blocked_by(),
308        parent: rec.heading.parent().map(str::to_string),
309        claimed_by: rec.heading.claimed_by().map(str::to_string),
310        claimed_at: rec.heading.claimed_at().map(str::to_string),
311        file: format!(
312            "{}:{}-{}",
313            rec.path.display(),
314            rec.heading.line_start,
315            rec.heading.line_end
316        ),
317        line_start: rec.heading.line_start,
318        line_end: rec.heading.line_end,
319        body: rec.heading.body.trim_end().to_string(),
320        logbook: rec
321            .heading
322            .logbook
323            .iter()
324            .map(|e| crate::views::LogbookLine {
325                timestamp: e.timestamp.clone(),
326                from_state: e.from_state.clone(),
327                to_state: e.to_state.clone(),
328                note: e.note.clone(),
329                raw: e.raw.clone(),
330            })
331            .collect(),
332    }
333}
334
335/// On-disk heading range, capped and screened for secrets.
336///
337/// # Errors
338///
339/// Returns an error if the heading's file cannot be read.
340pub fn excerpt_from(rec: &IssueRec) -> Result<Excerpt> {
341    let content = fs::read_to_string(&rec.path)?;
342    let lines: Vec<&str> = content.lines().collect();
343    let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
344    let to = rec
345        .heading
346        .line_end
347        .min(lines.len())
348        .min(from + BODY_EXCERPT_MAX_LINES);
349    let mut text = lines[from..to].join("\n");
350    if text.len() > BODY_EXCERPT_MAX_CHARS {
351        text.truncate(BODY_EXCERPT_MAX_CHARS);
352        text.push_str("\n...");
353    }
354    let suppressed = match secret_marker(&text) {
355        Some(marker) => {
356            text = format!(
357                "(excerpt suppressed: {marker} looks like secret material; open {} directly)\n",
358                rec.path.display()
359            );
360            true
361        }
362        None => false,
363    };
364    Ok(Excerpt {
365        id: rec.heading.id.clone(),
366        file: rec.path.display().to_string(),
367        line_start: rec.heading.line_start,
368        line_end: rec.heading.line_end,
369        text,
370        suppressed,
371    })
372}
373
374/// The heading's on-disk text in full, screened for secrets.
375///
376/// [`excerpt_from`] caps its output at the preview line cap, which is
377/// right for a preview and wrong for handing the issue to someone as a
378/// specification: an issue longer than the cap loses its tail silently. This
379/// returns the whole range, so what comes back is what the file holds.
380///
381/// The secret screen stays: a heading that carries credential-shaped text is
382/// refused here exactly as it is in a preview.
383///
384/// # Errors
385///
386/// Returns an error if the heading's file cannot be read, or the heading
387/// looks like secret material.
388pub fn org_text_from(rec: &IssueRec) -> Result<String> {
389    let content = fs::read_to_string(&rec.path)?;
390    let lines: Vec<&str> = content.lines().collect();
391    let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
392    let to = rec.heading.line_end.min(lines.len()).max(from);
393    let text = lines[from..to].join("\n");
394    if let Some(marker) = secret_marker(&text) {
395        return Err(Error::Other(anyhow::anyhow!(
396            "{} looks like secret material; open {} directly",
397            marker,
398            rec.path.display()
399        )));
400    }
401    Ok(text)
402}
403
404/// Text shape of [`crate::agent::body_excerpt`].
405pub(crate) fn format_body_excerpt(excerpt: &Excerpt) -> String {
406    if excerpt.suppressed {
407        return excerpt.text.clone();
408    }
409    let from = excerpt.line_start.saturating_sub(1);
410    let to = excerpt.line_end.min(from + BODY_EXCERPT_MAX_LINES);
411    format!(
412        "id: {}\nfile: {}:{}-{}\n--- excerpt (lines {}-{}) ---\n{}\n",
413        excerpt.id,
414        excerpt.file,
415        excerpt.line_start,
416        excerpt.line_end,
417        from + 1,
418        to,
419        excerpt.text
420    )
421}
422
423/// The marker that makes an excerpt look like it carries a credential.
424///
425/// A guard against handing an agent a secret by accident, not a redaction
426/// guarantee: it screens the shapes credentials are usually written in, and
427/// SECURITY.md says plainly that the answer is to keep them out of issue
428/// bodies. Widening it is cheap; relying on it is not.
429pub(crate) fn secret_marker(excerpt: &str) -> Option<&'static str> {
430    let lower = excerpt.to_lowercase();
431    // PEM and OpenSSH private key blocks, whatever the algorithm.
432    if lower.contains("-----begin") && lower.contains("private key") {
433        return Some("a private key block");
434    }
435    for token in [
436        "private_key",
437        "secret_key",
438        "client_secret",
439        "access_token",
440        "refresh_token",
441        "bearer ",
442        "authorization:",
443        "aws_secret_access_key",
444        "begin rsa",
445        "begin openssh",
446        "begin pgp private",
447    ] {
448        if lower.contains(token) {
449            return Some("a credential keyword");
450        }
451    }
452    // `key = value` shapes: an assignment whose name reads like a credential
453    // and whose value holds no space, which prose after a colon usually does.
454    // Judged on the name, not on how random the value looks: a guard should
455    // suppress a placeholder in an `api_key =` line rather than reason about
456    // whether this particular one is live.
457    for line in lower.lines() {
458        let Some((name, value)) = line.split_once(['=', ':']) else {
459            continue;
460        };
461        let name = name
462            .trim()
463            .trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
464        let value = value.trim().trim_matches(['"', '\'']);
465        if value.len() < 12 || value.contains(char::is_whitespace) {
466            continue;
467        }
468        if ["password", "passwd", "api_key", "apikey", "token", "secret"]
469            .iter()
470            .any(|needle| name.ends_with(needle))
471        {
472            return Some("an assignment to a credential name");
473        }
474    }
475    // Token prefixes, matched on a whole word and in the case they are
476    // issued in. A substring test here is what turns "making" into a cloud
477    // key and "task-force" into an API one.
478    for word in excerpt.split(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
479        let word = word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-');
480        if word.len() < 12 {
481            continue;
482        }
483        for prefix in [
484            "ghp_",
485            "gho_",
486            "ghs_",
487            "github_pat_",
488            "xoxb-",
489            "xoxp-",
490            "xoxa-",
491            "xoxs-",
492            "sk-",
493            "AKIA",
494            "ASIA",
495            "glpat-",
496        ] {
497            if word.starts_with(prefix) {
498                return Some("a vendor token prefix");
499            }
500        }
501    }
502    None
503}
504
505/// Case-insensitive substring scan over id, title, properties, and body.
506///
507/// # Errors
508///
509/// Does not fail for a parsed catalog.
510pub fn search_hits_from(issues: &[IssueRec], query: &str, limit: usize) -> Result<Vec<SearchHit>> {
511    let needle = query.to_lowercase();
512    let mut hits: Vec<(char, String, String, SearchHit)> = Vec::new();
513    for rec in issues {
514        let h = &rec.heading;
515        if !search_haystack(h).to_lowercase().contains(&needle) {
516            continue;
517        }
518        hits.push((
519            h.priority,
520            h.state.clone(),
521            h.id.clone(),
522            SearchHit {
523                id: h.id.clone(),
524                project: rec.project.clone(),
525                state: h.state.clone(),
526                priority: h.priority.to_string(),
527                title: h.title.clone(),
528                snippet: search_snippet(h, &needle),
529            },
530        ));
531    }
532    hits.sort_by(|a, b| {
533        a.0.cmp(&b.0)
534            .then_with(|| a.1.cmp(&b.1))
535            .then_with(|| a.2.cmp(&b.2))
536    });
537    hits.truncate(limit);
538    Ok(hits.into_iter().map(|h| h.3).collect())
539}
540
541fn search_haystack(h: &IssueHeading) -> String {
542    let mut hay = String::new();
543    hay.push_str(&h.id);
544    hay.push(' ');
545    hay.push_str(&h.title);
546    hay.push(' ');
547    for (k, v) in &h.properties {
548        hay.push_str(k);
549        hay.push(':');
550        hay.push_str(v);
551        hay.push(' ');
552    }
553    for tag in h.tags() {
554        hay.push_str(&tag);
555        hay.push(' ');
556    }
557    hay.push_str(&h.body);
558    hay
559}
560
561fn search_snippet(h: &IssueHeading, needle: &str) -> String {
562    let mut candidates = vec![h.id.clone(), h.title.clone()];
563    for (k, v) in &h.properties {
564        candidates.push(format!("{k}:{v}"));
565    }
566    candidates.extend(h.tags());
567    candidates.extend(h.body.lines().map(str::to_string));
568    let found = candidates
569        .into_iter()
570        .find(|line| line.to_lowercase().contains(needle))
571        .unwrap_or_else(|| h.title.clone());
572    const CAP: usize = 160;
573    if found.chars().count() > CAP {
574        let mut cut: String = found.chars().take(CAP).collect();
575        cut.push_str("...");
576        cut
577    } else {
578        found
579    }
580}
581
582/// Live claims, oldest first, optionally narrowed by holder or project.
583///
584/// # Errors
585///
586/// Does not fail for a parsed catalog.
587pub fn claims_from(
588    issues: &[IssueRec],
589    holder: Option<&str>,
590    project: Option<&str>,
591) -> Result<Vec<ClaimRow>> {
592    let today = Local::now().date_naive();
593    let mut rows: Vec<(String, ClaimRow)> = Vec::new();
594    for rec in issues {
595        if !project_selected(&rec.project, project) {
596            continue;
597        }
598        let Some(who) = rec.heading.claimed_by() else {
599            continue;
600        };
601        if let Some(filter) = holder
602            && who != filter
603        {
604            continue;
605        }
606        let age = rec
607            .heading
608            .claimed_at()
609            .and_then(parse_org_date)
610            .map(|d| (today - d).num_days())
611            .unwrap_or(-1);
612        rows.push((
613            rec.heading.claimed_at().unwrap_or("").to_string(),
614            ClaimRow {
615                id: rec.heading.id.clone(),
616                project: rec.project.clone(),
617                state: rec.heading.state.clone(),
618                priority: rec.heading.priority.to_string(),
619                holder: Some(who.to_string()),
620                claimed_at: rec.heading.claimed_at().map(str::to_string),
621                age_days: age,
622                title: rec.heading.title.clone(),
623            },
624        ));
625    }
626    rows.sort_by(|a, b| a.0.cmp(&b.0));
627    Ok(rows.into_iter().map(|r| r.1).collect())
628}
629
630/// Dated open work in the next `days` days, plus anything already overdue.
631///
632/// # Errors
633///
634/// Does not fail for a parsed catalog.
635pub fn agenda_rows_from(
636    issues: &[IssueRec],
637    days: i64,
638    project: Option<&str>,
639) -> Result<Vec<AgendaRow>> {
640    let today = Local::now().date_naive();
641    let horizon = today + chrono::Duration::days(days);
642    let mut rows: Vec<(chrono::NaiveDate, char, AgendaRow)> = Vec::new();
643    for rec in issues {
644        if !project_selected(&rec.project, project) {
645            continue;
646        }
647        let h = &rec.heading;
648        if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
649            continue;
650        }
651        for (kind_ch, kind, value) in [
652            ('D', "deadline", h.deadline()),
653            ('S', "scheduled", h.scheduled()),
654        ] {
655            let Some(parsed) = value.and_then(parse_org_date) else {
656                continue;
657            };
658            if parsed > horizon {
659                continue;
660            }
661            let delta = (parsed - today).num_days();
662            rows.push((
663                parsed,
664                kind_ch,
665                AgendaRow {
666                    date: parsed.to_string(),
667                    kind: kind.to_string(),
668                    overdue_days: if delta < 0 { -delta } else { 0 },
669                    id: h.id.clone(),
670                    project: rec.project.clone(),
671                    state: h.state.clone(),
672                    priority: h.priority.to_string(),
673                    title: h.title.clone(),
674                },
675            ));
676        }
677    }
678    rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.id.cmp(&b.2.id)));
679    Ok(rows.into_iter().map(|r| r.2).collect())
680}
681
682/// Parent/child subtree rooted at `id`.
683///
684/// # Errors
685///
686/// Returns an error if `id` is not in the catalog.
687pub fn tree_from(issues: &[IssueRec], id: &str) -> Result<TreeNode> {
688    if !issues.iter().any(|r| r.heading.id == id) {
689        return Err(Error::IssueNotFound { id: id.to_string() });
690    }
691    let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
692    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
693    for rec in issues {
694        by_id.insert(rec.heading.id.as_str(), &rec.heading);
695        if let Some(parent) = rec.heading.parent() {
696            children
697                .entry(parent)
698                .or_default()
699                .push(rec.heading.id.as_str());
700        }
701    }
702    for kids in children.values_mut() {
703        kids.sort_unstable();
704    }
705    Ok(build_tree(id, &by_id, &children, &mut HashSet::new()))
706}
707
708fn build_tree<'a>(
709    id: &'a str,
710    by_id: &HashMap<&'a str, &'a IssueHeading>,
711    children: &HashMap<&'a str, Vec<&'a str>>,
712    seen: &mut HashSet<&'a str>,
713) -> TreeNode {
714    if !seen.insert(id) {
715        return TreeNode {
716            id: id.to_string(),
717            state: String::new(),
718            title: String::new(),
719            children: Vec::new(),
720            blocked_by: Vec::new(),
721        };
722    }
723    let Some(h) = by_id.get(id) else {
724        return TreeNode {
725            id: id.to_string(),
726            state: String::new(),
727            title: String::new(),
728            children: Vec::new(),
729            blocked_by: Vec::new(),
730        };
731    };
732    let kids = children
733        .get(id)
734        .into_iter()
735        .flatten()
736        .map(|kid| build_tree(kid, by_id, children, seen))
737        .collect();
738    TreeNode {
739        id: h.id.clone(),
740        state: h.state.clone(),
741        title: h.title.clone(),
742        children: kids,
743        blocked_by: h.blocked_by(),
744    }
745}
746
747/// Issues whose `:PARENT:` points at `parent_id`.
748///
749/// # Errors
750///
751/// Returns an error if `parent_id` is not in the catalog and no children exist.
752pub fn children_from(issues: &[IssueRec], parent_id: &str) -> Result<Vec<WalkHit>> {
753    let mut rows: Vec<(char, String, String, WalkHit)> = Vec::new();
754    for rec in issues {
755        if rec.heading.parent() == Some(parent_id) {
756            rows.push((
757                rec.heading.priority,
758                rec.heading.state.clone(),
759                rec.heading.id.clone(),
760                walk_hit(rec, "child"),
761            ));
762        }
763    }
764    if rows.is_empty() && !known_issue_id(issues, parent_id) {
765        return Err(Error::IssueNotFound {
766            id: parent_id.to_string(),
767        });
768    }
769    rows.sort_by(|a, b| {
770        a.0.cmp(&b.0)
771            .then_with(|| a.1.cmp(&b.1))
772            .then_with(|| a.2.cmp(&b.2))
773    });
774    Ok(rows.into_iter().map(|r| r.3).collect())
775}
776
777enum WalkKind {
778    Ancestors,
779    Impact,
780}
781
782fn walk_from(issues: &[IssueRec], id: &str, depth: usize, kind: WalkKind) -> Result<Vec<WalkHit>> {
783    let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
784    let walked = match kind {
785        WalkKind::Ancestors => graph.ancestors(id, depth)?,
786        WalkKind::Impact => graph.descendants(id, depth)?,
787    };
788    let relation = match kind {
789        WalkKind::Ancestors => "ancestor",
790        WalkKind::Impact => "descendant",
791    };
792    Ok(walked
793        .into_iter()
794        .filter_map(|(_distance, other)| {
795            issues
796                .iter()
797                .find(|r| r.heading.id == other)
798                .map(|r| walk_hit(r, relation))
799        })
800        .collect())
801}
802
803/// Issues that refer to `target_id` through an edge, a parent, a
804/// discovered-from or pivoted-to property, or a body mention.
805///
806/// # Errors
807///
808/// Returns an error if `target_id` is not in the catalog and no backlinks exist.
809pub fn backlinks_from(issues: &[IssueRec], target_id: &str) -> Result<Vec<WalkHit>> {
810    let mut out = Vec::new();
811    for rec in issues {
812        if rec.heading.id == target_id {
813            continue;
814        }
815        let mut hit = false;
816        if rec.heading.blocked_by().iter().any(|b| b == target_id) {
817            out.push(walk_hit(rec, "blocked-by"));
818            hit = true;
819        }
820        if rec.heading.parent() == Some(target_id) {
821            out.push(walk_hit(rec, "parent"));
822            hit = true;
823        }
824        if rec
825            .heading
826            .properties
827            .get("DISCOVERED_FROM")
828            .map(String::as_str)
829            == Some(target_id)
830        {
831            out.push(walk_hit(rec, "discovered-from"));
832            hit = true;
833        }
834        if rec.heading.properties.get("PIVOTED_TO").map(String::as_str) == Some(target_id) {
835            out.push(walk_hit(rec, "pivoted-to"));
836            hit = true;
837        }
838        if !hit && rec.heading.body.contains(target_id) {
839            out.push(walk_hit(rec, "body mention"));
840        }
841    }
842    if out.is_empty() && !known_issue_id(issues, target_id) {
843        return Err(Error::IssueNotFound {
844            id: target_id.to_string(),
845        });
846    }
847    Ok(out)
848}
849
850/// Children and blockers below `id` as indented text or Graphviz DOT.
851///
852/// # Errors
853///
854/// Returns an error if `id` is not in the catalog, or `format` is not
855/// `ascii`, `text`, or `dot`.
856pub fn tree_text_from(issues: &[IssueRec], id: &str, format: &str) -> Result<String> {
857    if !issues.iter().any(|r| r.heading.id == id) {
858        return Err(Error::IssueNotFound { id: id.to_string() });
859    }
860    let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
861    let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
862    let mut blockers: HashMap<&str, Vec<String>> = HashMap::new();
863    for rec in issues {
864        by_id.insert(rec.heading.id.as_str(), &rec.heading);
865        if let Some(parent) = rec.heading.parent() {
866            children
867                .entry(parent)
868                .or_default()
869                .push(rec.heading.id.as_str());
870        }
871        let blocked = rec.heading.blocked_by();
872        if !blocked.is_empty() {
873            blockers.insert(rec.heading.id.as_str(), blocked);
874        }
875    }
876    for kids in children.values_mut() {
877        kids.sort_unstable();
878    }
879    let mut out = String::new();
880    match format {
881        "ascii" | "text" => tree_ascii_from(
882            id,
883            0,
884            &by_id,
885            &children,
886            &blockers,
887            &mut HashSet::new(),
888            &mut out,
889        ),
890        "dot" => tree_dot_from(id, &by_id, &children, &blockers, &mut out),
891        other => {
892            return Err(Error::Other(anyhow::anyhow!(
893                "unknown format {other:?}; allowed: ascii, dot"
894            )));
895        }
896    }
897    Ok(out)
898}
899
900fn tree_ascii_from<'a>(
901    id: &'a str,
902    depth: usize,
903    by_id: &HashMap<&str, &IssueHeading>,
904    children: &HashMap<&str, Vec<&'a str>>,
905    blockers: &'a HashMap<&str, Vec<String>>,
906    seen: &mut HashSet<&'a str>,
907    out: &mut String,
908) {
909    use std::fmt::Write as _;
910    if !seen.insert(id) {
911        let _ = writeln!(out, "{}{id} (cycle, stopping)", "  ".repeat(depth));
912        return;
913    }
914    let Some(h) = by_id.get(id) else {
915        let _ = writeln!(out, "{}{id} (missing)", "  ".repeat(depth));
916        return;
917    };
918    let _ = writeln!(
919        out,
920        "{}{id} {:<9} [#{}]  {}",
921        "  ".repeat(depth),
922        h.state,
923        h.priority,
924        h.title
925    );
926    if let Some(blocked) = blockers.get(id) {
927        for blocker in blocked {
928            let _ = writeln!(out, "{}* blocked-by {blocker}", "  ".repeat(depth + 1));
929        }
930    }
931    if let Some(kids) = children.get(id) {
932        for kid in kids {
933            tree_ascii_from(kid, depth + 1, by_id, children, blockers, seen, out);
934        }
935    }
936}
937
938fn tree_dot_from<'a>(
939    root_id: &'a str,
940    by_id: &HashMap<&str, &IssueHeading>,
941    children: &HashMap<&str, Vec<&'a str>>,
942    blockers: &'a HashMap<&str, Vec<String>>,
943    out: &mut String,
944) {
945    use std::fmt::Write as _;
946    let _ = writeln!(out, "digraph vissue_tree {{");
947    let _ = writeln!(out, "  rankdir=LR;");
948    let _ = writeln!(
949        out,
950        "  node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
951    );
952    let mut visited: HashSet<&str> = HashSet::new();
953    let mut stack = vec![root_id];
954    while let Some(id) = stack.pop() {
955        if !visited.insert(id) {
956            continue;
957        }
958        if let Some(h) = by_id.get(id) {
959            let _ = writeln!(
960                out,
961                "  \"{}\" [label=\"{}\\n{} [#{}]\"];",
962                dot_quoted(&h.id),
963                dot_quoted(&h.title),
964                dot_quoted(&h.state),
965                dot_quoted(&h.priority.to_string())
966            );
967            if let Some(kids) = children.get(id) {
968                for kid in kids {
969                    let _ = writeln!(
970                        out,
971                        "  \"{}\" -> \"{}\" [color=\"#00897B\"];",
972                        dot_quoted(&h.id),
973                        dot_quoted(kid)
974                    );
975                    stack.push(kid);
976                }
977            }
978            if let Some(blocked) = blockers.get(id) {
979                for b in blocked {
980                    let _ = writeln!(
981                        out,
982                        "  \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
983                        dot_quoted(b),
984                        dot_quoted(&h.id)
985                    );
986                    stack.push(b.as_str());
987                }
988            }
989        }
990    }
991    let _ = writeln!(out, "}}");
992}
993
994fn dot_quoted(text: &str) -> String {
995    text.replace('\\', "\\\\")
996        .replace('"', "\\\"")
997        .replace('\n', "\\n")
998        .replace('\r', "")
999}
1000
1001fn known_issue_id(issues: &[IssueRec], id: &str) -> bool {
1002    issues.iter().any(|r| r.heading.id == id)
1003}
1004
1005fn walk_hit(rec: &IssueRec, relation: &str) -> WalkHit {
1006    WalkHit {
1007        id: rec.heading.id.clone(),
1008        project: rec.project.clone(),
1009        state: rec.heading.state.clone(),
1010        title: rec.heading.title.clone(),
1011        relation: relation.to_string(),
1012    }
1013}