1use 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
22pub 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
40pub 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
126pub 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
259pub 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
294pub 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
319pub(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
338pub(crate) fn secret_marker(excerpt: &str) -> Option<&'static str> {
345 let lower = excerpt.to_lowercase();
346 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 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 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
740pub 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}