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