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