1use 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
22pub 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#[derive(Debug)]
47pub struct CatalogService<'a> {
48 issues: &'a [IssueRec],
49}
50
51impl<'a> CatalogService<'a> {
52 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 pub fn issues_rows(&self, q: ListQuery) -> Result<Vec<IssueRow>> {
70 issues_rows_from(self.issues, q)
71 }
72
73 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 pub fn detail(&self, id: &str) -> Result<IssueDetail> {
95 Ok(issue_detail(self.rec(id)?))
96 }
97
98 pub fn excerpt(&self, id: &str) -> Result<Excerpt> {
105 excerpt_from(self.rec(id)?)
106 }
107
108 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
114 search_hits_from(self.issues, query, limit)
115 }
116
117 pub fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>> {
123 claims_from(self.issues, holder, project)
124 }
125
126 pub fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>> {
132 agenda_rows_from(self.issues, days, project)
133 }
134
135 pub fn tree(&self, id: &str) -> Result<TreeNode> {
141 tree_from(self.issues, id)
142 }
143
144 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 pub fn children(&self, id: &str) -> Result<Vec<WalkHit>> {
164 children_from(self.issues, id)
165 }
166
167 pub fn ancestors(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
174 walk_from(self.issues, id, depth, WalkKind::Ancestors)
175 }
176
177 pub fn impact(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
184 walk_from(self.issues, id, depth, WalkKind::Impact)
185 }
186
187 pub fn backlinks(&self, id: &str) -> Result<Vec<WalkHit>> {
193 backlinks_from(self.issues, id)
194 }
195}
196
197pub 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
334pub 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
373pub 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
403pub(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
422pub(crate) fn secret_marker(excerpt: &str) -> Option<&'static str> {
429 let lower = excerpt.to_lowercase();
430 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 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 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
504pub 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
581pub 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
629pub 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
681pub 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
746pub 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
802pub 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
844pub 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}