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