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, Recall, RecallInput,
16 SearchHit, TreeNode, 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 use rayon::prelude::*;
33 let per_project: Vec<Vec<IssueRec>> = list_projects(layout)?
34 .into_par_iter()
35 .map(|project| {
36 let path = layout.project_issues_path(&project);
37 let doc = IssueDoc::parse_file(&project, &path)?;
38 let tag_settings = doc.tag_settings;
39 Ok(doc
40 .headings
41 .into_iter()
42 .map(|heading| IssueRec {
43 project: project.clone(),
44 heading,
45 path: path.clone(),
46 tag_settings: tag_settings.clone(),
47 })
48 .collect())
49 })
50 .collect::<Result<Vec<_>>>()?;
51 Ok(per_project.into_iter().flatten().collect())
52}
53
54#[derive(Debug)]
56pub struct CatalogService<'a> {
57 issues: &'a [IssueRec],
58}
59
60impl<'a> CatalogService<'a> {
61 pub fn from_recs(issues: &'a [IssueRec]) -> Self {
63 Self { issues }
64 }
65
66 fn rec(&self, id: &str) -> Result<&IssueRec> {
67 self.issues
68 .iter()
69 .find(|r| r.heading.id == id)
70 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })
71 }
72
73 pub fn issues_rows(&self, q: ListQuery) -> Result<Vec<IssueRow>> {
79 issues_rows_from(self.issues, q)
80 }
81
82 pub fn ready(&self, project: Option<&str>) -> Result<Vec<IssueRow>> {
88 issues_rows_from(
89 self.issues,
90 ListQuery {
91 project: project.map(str::to_string),
92 ready: true,
93 ..ListQuery::default()
94 },
95 )
96 }
97
98 pub fn detail(&self, id: &str) -> Result<IssueDetail> {
104 Ok(issue_detail(self.rec(id)?))
105 }
106
107 pub fn excerpt(&self, id: &str) -> Result<Excerpt> {
114 excerpt_from(self.rec(id)?)
115 }
116
117 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
123 search_hits_from(self.issues, query, limit)
124 }
125
126 pub fn claims(&self, holder: Option<&str>, project: Option<&str>) -> Result<Vec<ClaimRow>> {
132 claims_from(self.issues, holder, project)
133 }
134
135 pub fn agenda(&self, days: i64, project: Option<&str>) -> Result<Vec<AgendaRow>> {
141 agenda_rows_from(self.issues, days, project)
142 }
143
144 pub fn tree(&self, id: &str) -> Result<TreeNode> {
150 tree_from(self.issues, id)
151 }
152
153 pub fn related(
159 &self,
160 id: &str,
161 depth: usize,
162 limit: usize,
163 ) -> Result<Vec<crate::views::RelatedHit>> {
164 related_hits_from(self.issues, id, depth, limit)
165 }
166
167 pub fn children(&self, id: &str) -> Result<Vec<WalkHit>> {
173 children_from(self.issues, id)
174 }
175
176 pub fn ancestors(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
183 walk_from(self.issues, id, depth, WalkKind::Ancestors)
184 }
185
186 pub fn impact(&self, id: &str, depth: usize) -> Result<Vec<WalkHit>> {
193 walk_from(self.issues, id, depth, WalkKind::Impact)
194 }
195
196 pub fn recall(&self, id: &str, depth: usize, excerpts: bool) -> Result<crate::views::Recall> {
204 recall_from(self.issues, id, depth, excerpts)
205 }
206
207 pub fn backlinks(&self, id: &str) -> Result<Vec<WalkHit>> {
214 backlinks_from(self.issues, id)
215 }
216}
217
218pub fn issues_rows_from(issues: &[IssueRec], q: ListQuery) -> Result<Vec<IssueRow>> {
224 let active_blockers: HashSet<&str> = if q.ready {
225 issues
226 .iter()
227 .filter(|r| r.heading.state != "DONE" && r.heading.state != "CANCELLED")
228 .map(|r| r.heading.id.as_str())
229 .collect()
230 } else {
231 HashSet::new()
232 };
233 let ordering = q.ready.then(|| OrderingIndex::new(issues));
235
236 let mut rows: Vec<(char, String, String, IssueRow)> = Vec::new();
237 for rec in issues {
238 if !project_selected(&rec.project, q.project.as_deref()) {
239 continue;
240 }
241 if let Some(state) = q.state.as_deref()
242 && rec.heading.state != state
243 {
244 continue;
245 }
246 if q.ready {
247 if !READY_STATES.contains(&rec.heading.state.as_str()) {
248 continue;
249 }
250 if rec
251 .heading
252 .blocked_by()
253 .iter()
254 .any(|b| active_blockers.contains(b.as_str()))
255 {
256 continue;
257 }
258 if ordering
259 .as_ref()
260 .is_some_and(|index| ordered_sibling_holds(rec, index))
261 {
262 continue;
263 }
264 }
265 if let Some(needle) = q.query.as_deref()
266 && !list_query_matches(rec, needle)
267 {
268 continue;
269 }
270 rows.push((
271 rec.heading.priority,
272 rec.heading.state.clone(),
273 rec.heading.id.clone(),
274 issue_row(rec),
275 ));
276 }
277 rows.sort_by(|a, b| {
278 a.0.cmp(&b.0)
279 .then_with(|| a.1.cmp(&b.1))
280 .then_with(|| a.2.cmp(&b.2))
281 });
282 let mut out: Vec<IssueRow> = rows.into_iter().map(|r| r.3).collect();
283 let offset = q.offset.unwrap_or(0);
284 if offset >= out.len() {
285 out.clear();
286 } else if offset > 0 {
287 out = out.split_off(offset);
288 }
289 if let Some(limit) = q.limit {
290 out.truncate(limit);
291 }
292 Ok(out)
293}
294
295struct OrderingIndex<'a> {
297 by_id: HashMap<&'a str, &'a IssueRec>,
298 children: HashMap<&'a str, Vec<&'a IssueRec>>,
299}
300
301impl<'a> OrderingIndex<'a> {
302 fn new(issues: &'a [IssueRec]) -> Self {
303 let mut by_id = HashMap::with_capacity(issues.len());
304 let mut children: HashMap<&str, Vec<&IssueRec>> = HashMap::new();
305 for rec in issues {
306 by_id.insert(rec.heading.id.as_str(), rec);
307 if let Some(parent) = rec.heading.parent() {
308 children.entry(parent).or_default().push(rec);
309 }
310 }
311 Self { by_id, children }
312 }
313}
314
315fn ordered_sibling_holds(rec: &IssueRec, index: &OrderingIndex<'_>) -> bool {
316 if crate::org::org_property_is_set(&rec.heading.properties, "NOBLOCKING") {
317 return false;
318 }
319 let Some(parent_id) = rec.heading.parent() else {
320 return false;
321 };
322 let Some(parent) = index.by_id.get(parent_id) else {
323 return false;
324 };
325 if !crate::org::org_property_is_set(&parent.heading.properties, "ORDERED") {
326 return false;
327 }
328 index
329 .children
330 .get(parent_id)
331 .into_iter()
332 .flatten()
333 .any(|sib| {
334 sib.heading.id != rec.heading.id
335 && sib.heading.line_start < rec.heading.line_start
336 && sib.heading.state != "DONE"
337 && sib.heading.state != "CANCELLED"
338 })
339}
340
341fn list_query_matches(rec: &IssueRec, needle: &str) -> bool {
342 let h = &rec.heading;
343 let needle = needle.to_lowercase();
344 if h.id.to_lowercase().contains(&needle) || h.title.to_lowercase().contains(&needle) {
345 return true;
346 }
347 if rec.tag_settings.matches_query(&h.tags(), &needle) {
348 return true;
349 }
350 h.properties
351 .iter()
352 .any(|(k, v)| k.to_lowercase().contains(&needle) || v.to_lowercase().contains(&needle))
353}
354
355fn issue_row(rec: &IssueRec) -> IssueRow {
356 IssueRow {
357 id: rec.heading.id.clone(),
358 state: rec.heading.state.clone(),
359 priority: rec.heading.priority.to_string(),
360 title: rec.heading.title.clone(),
361 project: rec.project.clone(),
362 blocked_by: rec.heading.blocked_by(),
363 claimed_by: rec.heading.claimed_by().map(str::to_string),
364 claimed_at: rec.heading.claimed_at().map(str::to_string),
365 parent: rec.heading.parent().map(str::to_string),
366 }
367}
368
369fn issue_detail(rec: &IssueRec) -> IssueDetail {
370 IssueDetail {
371 id: rec.heading.id.clone(),
372 project: rec.project.clone(),
373 title: rec.heading.title.clone(),
374 state: rec.heading.state.clone(),
375 priority: rec.heading.priority.to_string(),
376 properties: rec.heading.properties.clone(),
377 org_tags: rec.heading.org_tags.clone(),
378 deeds: rec.heading.deeds(),
379 tags: rec.tag_settings.all_tags(&rec.heading.tags()),
380 blocked_by: rec.heading.blocked_by(),
381 parent: rec.heading.parent().map(str::to_string),
382 claimed_by: rec.heading.claimed_by().map(str::to_string),
383 claimed_at: rec.heading.claimed_at().map(str::to_string),
384 file: format!(
385 "{}:{}-{}",
386 rec.path.display(),
387 rec.heading.line_start,
388 rec.heading.line_end
389 ),
390 line_start: rec.heading.line_start,
391 line_end: rec.heading.line_end,
392 body: rec.heading.body.trim_end().to_string(),
393 logbook: rec
394 .heading
395 .logbook
396 .iter()
397 .map(|e| crate::views::LogbookLine {
398 timestamp: e.timestamp.clone(),
399 from_state: e.from_state.clone(),
400 to_state: e.to_state.clone(),
401 note: e.note.clone(),
402 raw: e.raw.clone(),
403 })
404 .collect(),
405 }
406}
407
408pub fn excerpt_from(rec: &IssueRec) -> Result<Excerpt> {
414 let content = fs::read_to_string(&rec.path)?;
415 let lines: Vec<&str> = content.lines().collect();
416 let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
417 let to = rec
418 .heading
419 .line_end
420 .min(lines.len())
421 .min(from + BODY_EXCERPT_MAX_LINES);
422 let mut text = lines[from..to].join("\n");
423 if text.len() > BODY_EXCERPT_MAX_CHARS {
424 text.truncate(BODY_EXCERPT_MAX_CHARS);
425 text.push_str("\n...");
426 }
427 let suppressed = match secret_marker(&text) {
428 Some(marker) => {
429 text = format!(
430 "(excerpt suppressed: {marker} looks like secret material; open {} directly)\n",
431 rec.path.display()
432 );
433 true
434 }
435 None => false,
436 };
437 Ok(Excerpt {
438 id: rec.heading.id.clone(),
439 file: rec.path.display().to_string(),
440 line_start: rec.heading.line_start,
441 line_end: rec.heading.line_end,
442 text,
443 suppressed,
444 })
445}
446
447pub fn org_text_from(rec: &IssueRec) -> Result<String> {
462 let content = fs::read_to_string(&rec.path)?;
463 let lines: Vec<&str> = content.lines().collect();
464 let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
465 let to = rec.heading.line_end.min(lines.len()).max(from);
466 let text = lines[from..to].join("\n");
467 if let Some(marker) = secret_marker(&text) {
468 return Err(Error::Other(anyhow::anyhow!(
469 "{} looks like secret material; open {} directly",
470 marker,
471 rec.path.display()
472 )));
473 }
474 Ok(text)
475}
476
477pub(crate) fn format_body_excerpt(excerpt: &Excerpt) -> String {
479 if excerpt.suppressed {
480 return excerpt.text.clone();
481 }
482 let from = excerpt.line_start.saturating_sub(1);
483 let to = excerpt.line_end.min(from + BODY_EXCERPT_MAX_LINES);
484 format!(
485 "id: {}\nfile: {}:{}-{}\n--- excerpt (lines {}-{}) ---\n{}\n",
486 excerpt.id,
487 excerpt.file,
488 excerpt.line_start,
489 excerpt.line_end,
490 from + 1,
491 to,
492 excerpt.text
493 )
494}
495
496pub(crate) fn secret_marker(excerpt: &str) -> Option<&'static str> {
503 let lower = excerpt.to_lowercase();
504 if lower.contains("-----begin") && lower.contains("private key") {
506 return Some("a private key block");
507 }
508 for token in [
509 "private_key",
510 "secret_key",
511 "client_secret",
512 "access_token",
513 "refresh_token",
514 "bearer ",
515 "authorization:",
516 "aws_secret_access_key",
517 "begin rsa",
518 "begin openssh",
519 "begin pgp private",
520 ] {
521 if lower.contains(token) {
522 return Some("a credential keyword");
523 }
524 }
525 for line in lower.lines() {
527 let Some((name, value)) = line.split_once(['=', ':']) else {
528 continue;
529 };
530 let name = name
531 .trim()
532 .trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
533 let value = value.trim().trim_matches(['"', '\'']);
534 if value.len() < 12 || value.contains(char::is_whitespace) {
535 continue;
536 }
537 if ["password", "passwd", "api_key", "apikey", "token", "secret"]
538 .iter()
539 .any(|needle| name.ends_with(needle))
540 {
541 return Some("an assignment to a credential name");
542 }
543 }
544 for word in excerpt.split(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
548 let word = word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-');
549 if word.len() < 12 {
550 continue;
551 }
552 for prefix in [
553 "ghp_",
554 "gho_",
555 "ghs_",
556 "github_pat_",
557 "xoxb-",
558 "xoxp-",
559 "xoxa-",
560 "xoxs-",
561 "sk-",
562 "AKIA",
563 "ASIA",
564 "glpat-",
565 ] {
566 if word.starts_with(prefix) {
567 return Some("a vendor token prefix");
568 }
569 }
570 }
571 None
572}
573
574pub fn search_hits_from(issues: &[IssueRec], query: &str, limit: usize) -> Result<Vec<SearchHit>> {
580 let needle = query.to_lowercase();
581 let mut hits: Vec<(char, String, String, SearchHit)> = Vec::new();
582 for rec in issues {
583 let h = &rec.heading;
584 if !search_haystack(rec).to_lowercase().contains(&needle)
585 && !rec.tag_settings.matches_query(&h.tags(), &needle)
586 {
587 continue;
588 }
589 hits.push((
590 h.priority,
591 h.state.clone(),
592 h.id.clone(),
593 SearchHit {
594 id: h.id.clone(),
595 project: rec.project.clone(),
596 state: h.state.clone(),
597 priority: h.priority.to_string(),
598 title: h.title.clone(),
599 snippet: search_snippet(rec, &needle),
600 },
601 ));
602 }
603 hits.sort_by(|a, b| {
604 a.0.cmp(&b.0)
605 .then_with(|| a.1.cmp(&b.1))
606 .then_with(|| a.2.cmp(&b.2))
607 });
608 hits.truncate(limit);
609 Ok(hits.into_iter().map(|h| h.3).collect())
610}
611
612fn search_haystack(rec: &IssueRec) -> String {
613 let h = &rec.heading;
614 let mut hay = String::new();
615 hay.push_str(&h.id);
616 hay.push(' ');
617 hay.push_str(&h.title);
618 hay.push(' ');
619 for (k, v) in &h.properties {
620 hay.push_str(k);
621 hay.push(':');
622 hay.push_str(v);
623 hay.push(' ');
624 }
625 for tag in rec.tag_settings.all_tags(&h.tags()) {
626 hay.push_str(&tag);
627 hay.push(' ');
628 }
629 hay.push_str(&h.body);
630 hay
631}
632
633fn search_snippet(rec: &IssueRec, needle: &str) -> String {
634 let h = &rec.heading;
635 let mut candidates = vec![h.id.clone(), h.title.clone()];
636 for (k, v) in &h.properties {
637 candidates.push(format!("{k}:{v}"));
638 }
639 candidates.extend(rec.tag_settings.all_tags(&h.tags()));
640 candidates.extend(h.body.lines().map(str::to_string));
641 let found = candidates
642 .into_iter()
643 .find(|line| line.to_lowercase().contains(needle))
644 .unwrap_or_else(|| h.title.clone());
645 const CAP: usize = 160;
646 if found.chars().count() > CAP {
647 let mut cut: String = found.chars().take(CAP).collect();
648 cut.push_str("...");
649 cut
650 } else {
651 found
652 }
653}
654
655pub fn claims_from(
661 issues: &[IssueRec],
662 holder: Option<&str>,
663 project: Option<&str>,
664) -> Result<Vec<ClaimRow>> {
665 let today = Local::now().date_naive();
666 let mut rows: Vec<(String, ClaimRow)> = Vec::new();
667 for rec in issues {
668 if !project_selected(&rec.project, project) {
669 continue;
670 }
671 let Some(who) = rec.heading.claimed_by() else {
672 continue;
673 };
674 if let Some(filter) = holder
675 && who != filter
676 {
677 continue;
678 }
679 let age = rec
680 .heading
681 .claimed_at()
682 .and_then(parse_org_date)
683 .map(|d| (today - d).num_days())
684 .unwrap_or(-1);
685 rows.push((
686 rec.heading.claimed_at().unwrap_or("").to_string(),
687 ClaimRow {
688 id: rec.heading.id.clone(),
689 project: rec.project.clone(),
690 state: rec.heading.state.clone(),
691 priority: rec.heading.priority.to_string(),
692 holder: Some(who.to_string()),
693 claimed_at: rec.heading.claimed_at().map(str::to_string),
694 age_days: age,
695 title: rec.heading.title.clone(),
696 },
697 ));
698 }
699 rows.sort_by(|a, b| a.0.cmp(&b.0));
700 Ok(rows.into_iter().map(|r| r.1).collect())
701}
702
703pub fn agenda_rows_from(
713 issues: &[IssueRec],
714 days: i64,
715 project: Option<&str>,
716) -> Result<Vec<AgendaRow>> {
717 let today = Local::now().date_naive();
718 let horizon = today + chrono::Duration::days(days);
719 let mut rows: Vec<AgendaRow> = Vec::new();
720 for rec in issues {
721 if !project_selected(&rec.project, project) {
722 continue;
723 }
724 let h = &rec.heading;
725 if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
726 continue;
727 }
728 let deadline = h.deadline().and_then(parse_org_date);
729 let scheduled = h.scheduled().and_then(parse_org_date);
730 for (kind, parsed) in [("deadline", deadline), ("scheduled", scheduled)] {
731 let Some(parsed) = parsed else {
732 continue;
733 };
734 if parsed > horizon {
735 continue;
736 }
737 let delta = (parsed - today).num_days();
738 rows.push(AgendaRow {
739 date: parsed.to_string(),
740 kind: kind.to_string(),
741 overdue_days: if delta < 0 { -delta } else { 0 },
742 id: h.id.clone(),
743 project: rec.project.clone(),
744 state: h.state.clone(),
745 priority: h.priority.to_string(),
746 title: h.title.clone(),
747 });
748 }
749 for parsed in active_stamps_in(&h.title) {
750 if deadline == Some(parsed) || scheduled == Some(parsed) {
751 continue;
752 }
753 if parsed < today || parsed > horizon {
754 continue;
755 }
756 rows.push(AgendaRow {
757 date: parsed.to_string(),
758 kind: "appointment".to_string(),
759 overdue_days: 0,
760 id: h.id.clone(),
761 project: rec.project.clone(),
762 state: h.state.clone(),
763 priority: h.priority.to_string(),
764 title: h.title.clone(),
765 });
766 }
767 }
768 rows.sort_by(|a, b| {
769 agenda_kind_rank(&a.kind)
770 .cmp(&agenda_kind_rank(&b.kind))
771 .then(b.overdue_days.cmp(&a.overdue_days))
772 .then(a.date.cmp(&b.date))
773 .then(a.id.cmp(&b.id))
774 });
775 Ok(rows)
776}
777
778fn agenda_kind_rank(kind: &str) -> u8 {
779 match kind {
780 "deadline" => 0,
781 "scheduled" => 1,
782 _ => 2,
783 }
784}
785
786fn active_stamps_in(text: &str) -> Vec<chrono::NaiveDate> {
788 let mut out = Vec::new();
789 let bytes = text.as_bytes();
790 let mut i = 0;
791 while i + 11 <= bytes.len() {
792 if bytes[i] == b'<'
793 && let Ok(slice) = std::str::from_utf8(&bytes[i + 1..i + 11])
794 && let Ok(date) = chrono::NaiveDate::parse_from_str(slice, "%Y-%m-%d")
795 {
796 out.push(date);
797 i += 11;
798 continue;
799 }
800 i += 1;
801 }
802 out
803}
804
805pub fn tree_from(issues: &[IssueRec], id: &str) -> Result<TreeNode> {
811 if !issues.iter().any(|r| r.heading.id == id) {
812 return Err(Error::IssueNotFound { id: id.to_string() });
813 }
814 let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
815 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
816 for rec in issues {
817 by_id.insert(rec.heading.id.as_str(), &rec.heading);
818 if let Some(parent) = rec.heading.parent() {
819 children
820 .entry(parent)
821 .or_default()
822 .push(rec.heading.id.as_str());
823 }
824 }
825 for kids in children.values_mut() {
826 kids.sort_unstable();
827 }
828 Ok(build_tree(id, &by_id, &children, &mut HashSet::new()))
829}
830
831fn build_tree<'a>(
832 id: &'a str,
833 by_id: &HashMap<&'a str, &'a IssueHeading>,
834 children: &HashMap<&'a str, Vec<&'a str>>,
835 seen: &mut HashSet<&'a str>,
836) -> TreeNode {
837 if !seen.insert(id) {
838 return TreeNode {
839 id: id.to_string(),
840 state: String::new(),
841 title: String::new(),
842 children: Vec::new(),
843 blocked_by: Vec::new(),
844 };
845 }
846 let Some(h) = by_id.get(id) else {
847 return TreeNode {
848 id: id.to_string(),
849 state: String::new(),
850 title: String::new(),
851 children: Vec::new(),
852 blocked_by: Vec::new(),
853 };
854 };
855 let kids = children
856 .get(id)
857 .into_iter()
858 .flatten()
859 .map(|kid| build_tree(kid, by_id, children, seen))
860 .collect();
861 TreeNode {
862 id: h.id.clone(),
863 state: h.state.clone(),
864 title: h.title.clone(),
865 children: kids,
866 blocked_by: h.blocked_by(),
867 }
868}
869
870pub fn children_from(issues: &[IssueRec], parent_id: &str) -> Result<Vec<WalkHit>> {
876 let mut rows: Vec<(char, String, String, WalkHit)> = Vec::new();
877 for rec in issues {
878 if rec.heading.parent() == Some(parent_id) {
879 rows.push((
880 rec.heading.priority,
881 rec.heading.state.clone(),
882 rec.heading.id.clone(),
883 walk_hit(rec, "child"),
884 ));
885 }
886 }
887 if rows.is_empty() && !known_issue_id(issues, parent_id) {
888 return Err(Error::IssueNotFound {
889 id: parent_id.to_string(),
890 });
891 }
892 rows.sort_by(|a, b| {
893 a.0.cmp(&b.0)
894 .then_with(|| a.1.cmp(&b.1))
895 .then_with(|| a.2.cmp(&b.2))
896 });
897 Ok(rows.into_iter().map(|r| r.3).collect())
898}
899
900enum WalkKind {
901 Ancestors,
902 Impact,
903}
904
905fn walk_from(issues: &[IssueRec], id: &str, depth: usize, kind: WalkKind) -> Result<Vec<WalkHit>> {
906 let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
907 let walked = match kind {
908 WalkKind::Ancestors => graph.ancestors(id, depth)?,
909 WalkKind::Impact => graph.descendants(id, depth)?,
910 };
911 let relation = match kind {
912 WalkKind::Ancestors => "ancestor",
913 WalkKind::Impact => "descendant",
914 };
915 Ok(walked
916 .into_iter()
917 .filter_map(|(_distance, other)| {
918 issues
919 .iter()
920 .find(|r| r.heading.id == other)
921 .map(|r| walk_hit(r, relation))
922 })
923 .collect())
924}
925
926pub fn recall_from(issues: &[IssueRec], id: &str, depth: usize, excerpts: bool) -> Result<Recall> {
944 let rec = issues
945 .iter()
946 .find(|r| r.heading.id == id)
947 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
948
949 let mut plan: Vec<WalkHit> = Vec::new();
950 let mut seen: HashSet<&str> = HashSet::from([id]);
951 let mut at = rec.heading.parent();
952 while let Some(parent) = at {
956 if !seen.insert(parent) {
957 break;
958 }
959 match issues.iter().find(|r| r.heading.id == parent) {
960 Some(prec) => {
961 plan.push(walk_hit(prec, "plan"));
962 at = prec.heading.parent();
963 }
964 None => {
965 plan.push(WalkHit {
971 id: parent.to_string(),
972 project: String::new(),
973 state: String::new(),
974 title: "(a heading outside the tracker)".to_string(),
975 relation: "plan".to_string(),
976 });
977 break;
978 }
979 }
980 }
981 plan.reverse();
982
983 let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
984 let mut walked = graph.ancestors(id, depth)?;
985 walked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
988 let mut inputs: Vec<RecallInput> = Vec::new();
989 for (distance, other) in walked {
990 let Some(orec) = issues.iter().find(|r| r.heading.id == other) else {
991 continue;
992 };
993 let relation = if distance == 1 {
994 "blocked-by".to_string()
995 } else {
996 format!("blocked-by:{distance}")
997 };
998 inputs.push(recall_input(orec, &relation, excerpts));
999 }
1000 if let Some(origin) = crate::props::get(&rec.heading.properties, crate::props::DISCOVERED_FROM)
1003 && !inputs.iter().any(|i| i.id == origin)
1004 && let Some(orec) = issues.iter().find(|r| r.heading.id == origin)
1005 {
1006 inputs.push(recall_input(orec, "discovered-from", excerpts));
1007 }
1008
1009 Ok(Recall {
1010 id: rec.heading.id.clone(),
1011 project: rec.project.clone(),
1012 state: rec.heading.state.clone(),
1013 title: rec.heading.title.clone(),
1014 plan,
1015 inputs,
1016 produced: rec.heading.deeds(),
1017 body: rec.heading.body.trim_end().to_string(),
1018 })
1019}
1020
1021fn recall_input(rec: &IssueRec, relation: &str, excerpts: bool) -> RecallInput {
1022 RecallInput {
1023 id: rec.heading.id.clone(),
1024 project: rec.project.clone(),
1025 state: rec.heading.state.clone(),
1026 title: rec.heading.title.clone(),
1027 relation: relation.to_string(),
1028 deeds: rec.heading.deeds(),
1029 excerpt: excerpts
1034 .then(|| excerpt_from(rec).ok().map(|e| e.text))
1035 .flatten(),
1036 last_note: rec
1040 .heading
1041 .logbook
1042 .iter()
1043 .filter(|entry| !entry.is_bookkeeping())
1044 .find_map(|entry| entry.note.clone()),
1045 }
1046}
1047
1048pub fn backlinks_from(issues: &[IssueRec], target_id: &str) -> Result<Vec<WalkHit>> {
1061 let mut out = Vec::new();
1062 if !known_issue_id(issues, target_id) && crate::ops::is_deed_accession(target_id) {
1063 for rec in issues {
1064 if rec.heading.deeds().iter().any(|cited| cited == target_id) {
1065 out.push(walk_hit(rec, "cites"));
1066 } else if rec.heading.body.contains(target_id) {
1067 out.push(walk_hit(rec, "body mention"));
1068 }
1069 }
1070 return Ok(out);
1074 }
1075 for rec in issues {
1076 if rec.heading.id == target_id {
1077 continue;
1078 }
1079 let mut hit = false;
1080 if rec.heading.blocked_by().iter().any(|b| b == target_id) {
1081 out.push(walk_hit(rec, "blocked-by"));
1082 hit = true;
1083 }
1084 if rec.heading.parent() == Some(target_id) {
1085 out.push(walk_hit(rec, "parent"));
1086 hit = true;
1087 }
1088 if rec
1089 .heading
1090 .properties
1091 .get("DISCOVERED_FROM")
1092 .map(String::as_str)
1093 == Some(target_id)
1094 {
1095 out.push(walk_hit(rec, "discovered-from"));
1096 hit = true;
1097 }
1098 if rec.heading.properties.get("PIVOTED_TO").map(String::as_str) == Some(target_id) {
1099 out.push(walk_hit(rec, "pivoted-to"));
1100 hit = true;
1101 }
1102 if !hit && rec.heading.body.contains(target_id) {
1103 out.push(walk_hit(rec, "body mention"));
1104 }
1105 }
1106 if out.is_empty() && !known_issue_id(issues, target_id) {
1107 return Err(Error::IssueNotFound {
1108 id: target_id.to_string(),
1109 });
1110 }
1111 Ok(out)
1112}
1113
1114pub fn tree_text_from(issues: &[IssueRec], id: &str, format: &str) -> Result<String> {
1121 if !issues.iter().any(|r| r.heading.id == id) {
1122 return Err(Error::IssueNotFound { id: id.to_string() });
1123 }
1124 let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
1125 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1126 let mut blockers: HashMap<&str, Vec<String>> = HashMap::new();
1127 for rec in issues {
1128 by_id.insert(rec.heading.id.as_str(), &rec.heading);
1129 if let Some(parent) = rec.heading.parent() {
1130 children
1131 .entry(parent)
1132 .or_default()
1133 .push(rec.heading.id.as_str());
1134 }
1135 let blocked = rec.heading.blocked_by();
1136 if !blocked.is_empty() {
1137 blockers.insert(rec.heading.id.as_str(), blocked);
1138 }
1139 }
1140 for kids in children.values_mut() {
1141 kids.sort_unstable();
1142 }
1143 let mut out = String::new();
1144 match format {
1145 "ascii" | "text" => tree_ascii_from(
1146 id,
1147 0,
1148 &by_id,
1149 &children,
1150 &blockers,
1151 &mut HashSet::new(),
1152 &mut out,
1153 ),
1154 "dot" => tree_dot_from(id, &by_id, &children, &blockers, &mut out),
1155 other => {
1156 return Err(Error::Other(anyhow::anyhow!(
1157 "unknown format {other:?}; allowed: ascii, dot"
1158 )));
1159 }
1160 }
1161 Ok(out)
1162}
1163
1164fn tree_ascii_from<'a>(
1165 id: &'a str,
1166 depth: usize,
1167 by_id: &HashMap<&str, &IssueHeading>,
1168 children: &HashMap<&str, Vec<&'a str>>,
1169 blockers: &'a HashMap<&str, Vec<String>>,
1170 seen: &mut HashSet<&'a str>,
1171 out: &mut String,
1172) {
1173 use std::fmt::Write as _;
1174 if !seen.insert(id) {
1175 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
1176 return;
1177 }
1178 let Some(h) = by_id.get(id) else {
1179 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
1180 return;
1181 };
1182 let _ = writeln!(
1183 out,
1184 "{}{id} {:<9} [#{}] {}",
1185 " ".repeat(depth),
1186 h.state,
1187 h.priority,
1188 h.title
1189 );
1190 if let Some(blocked) = blockers.get(id) {
1191 for blocker in blocked {
1192 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
1193 }
1194 }
1195 if let Some(kids) = children.get(id) {
1196 for kid in kids {
1197 tree_ascii_from(kid, depth + 1, by_id, children, blockers, seen, out);
1198 }
1199 }
1200}
1201
1202fn tree_dot_from<'a>(
1203 root_id: &'a str,
1204 by_id: &HashMap<&str, &IssueHeading>,
1205 children: &HashMap<&str, Vec<&'a str>>,
1206 blockers: &'a HashMap<&str, Vec<String>>,
1207 out: &mut String,
1208) {
1209 use std::fmt::Write as _;
1210 let _ = writeln!(out, "digraph vissue_tree {{");
1211 let _ = writeln!(out, " rankdir=LR;");
1212 let _ = writeln!(
1213 out,
1214 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1215 );
1216 let mut visited: HashSet<&str> = HashSet::new();
1217 let mut stack = vec![root_id];
1218 while let Some(id) = stack.pop() {
1219 if !visited.insert(id) {
1220 continue;
1221 }
1222 if let Some(h) = by_id.get(id) {
1223 let _ = writeln!(
1224 out,
1225 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
1226 dot_quoted(&h.id),
1227 dot_quoted(&h.title),
1228 dot_quoted(&h.state),
1229 dot_quoted(&h.priority.to_string())
1230 );
1231 if let Some(kids) = children.get(id) {
1232 for kid in kids {
1233 let _ = writeln!(
1234 out,
1235 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
1236 dot_quoted(&h.id),
1237 dot_quoted(kid)
1238 );
1239 stack.push(kid);
1240 }
1241 }
1242 if let Some(blocked) = blockers.get(id) {
1243 for b in blocked {
1244 let _ = writeln!(
1245 out,
1246 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1247 dot_quoted(b),
1248 dot_quoted(&h.id)
1249 );
1250 stack.push(b.as_str());
1251 }
1252 }
1253 }
1254 }
1255 let _ = writeln!(out, "}}");
1256}
1257
1258fn dot_quoted(text: &str) -> String {
1259 text.replace('\\', "\\\\")
1260 .replace('"', "\\\"")
1261 .replace('\n', "\\n")
1262 .replace('\r', "")
1263}
1264
1265fn known_issue_id(issues: &[IssueRec], id: &str) -> bool {
1266 issues.iter().any(|r| r.heading.id == id)
1267}
1268
1269fn walk_hit(rec: &IssueRec, relation: &str) -> WalkHit {
1270 WalkHit {
1271 id: rec.heading.id.clone(),
1272 project: rec.project.clone(),
1273 state: rec.heading.state.clone(),
1274 title: rec.heading.title.clone(),
1275 relation: relation.to_string(),
1276 }
1277}