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