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 tag_settings: doc.tag_settings.clone(),
40 });
41 }
42 }
43 Ok(recs)
44}
45
46#[derive(Debug)]
48pub struct CatalogService<'a> {
49 issues: &'a [IssueRec],
50}
51
52impl<'a> CatalogService<'a> {
53 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 pub fn issues_rows(&self, q: ListQuery) -> Result<Vec<IssueRow>> {
71 issues_rows_from(self.issues, q)
72 }
73
74 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 pub fn detail(&self, id: &str) -> Result<IssueDetail> {
96 Ok(issue_detail(self.rec(id)?))
97 }
98
99 pub fn excerpt(&self, id: &str) -> Result<Excerpt> {
106 excerpt_from(self.rec(id)?)
107 }
108
109 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
115 search_hits_from(self.issues, query, limit)
116 }
117
118 pub fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>> {
124 claims_from(self.issues, holder, project)
125 }
126
127 pub fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>> {
133 agenda_rows_from(self.issues, days, project)
134 }
135
136 pub fn tree(&self, id: &str) -> Result<TreeNode> {
142 tree_from(self.issues, id)
143 }
144
145 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 pub fn children(&self, id: &str) -> Result<Vec<WalkHit>> {
165 children_from(self.issues, id)
166 }
167
168 pub fn ancestors(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
175 walk_from(self.issues, id, depth, WalkKind::Ancestors)
176 }
177
178 pub fn impact(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
185 walk_from(self.issues, id, depth, WalkKind::Impact)
186 }
187
188 pub fn backlinks(&self, id: &str) -> Result<Vec<WalkHit>> {
195 backlinks_from(self.issues, id)
196 }
197}
198
199pub 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
359pub 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
398pub 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
428pub(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
447pub(crate) fn secret_marker(excerpt: &str) -> Option<&'static str> {
454 let lower = excerpt.to_lowercase();
455 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 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 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
529pub 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
610pub 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
658pub 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
710pub 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
775pub 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
831pub 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
878pub 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}