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));
239
240 let mut rows: Vec<(char, String, String, IssueRow)> = Vec::new();
241 for rec in issues {
242 if !project_selected(&rec.project, q.project.as_deref()) {
243 continue;
244 }
245 if let Some(state) = q.state.as_deref()
246 && rec.heading.state != state
247 {
248 continue;
249 }
250 if q.ready {
251 if !READY_STATES.contains(&rec.heading.state.as_str()) {
252 continue;
253 }
254 if rec
255 .heading
256 .blocked_by()
257 .iter()
258 .any(|b| active_blockers.contains(b.as_str()))
259 {
260 continue;
261 }
262 if ordering
263 .as_ref()
264 .is_some_and(|index| ordered_sibling_holds(rec, index))
265 {
266 continue;
267 }
268 }
269 if let Some(needle) = q.query.as_deref()
270 && !list_query_matches(rec, needle)
271 {
272 continue;
273 }
274 rows.push((
275 rec.heading.priority,
276 rec.heading.state.clone(),
277 rec.heading.id.clone(),
278 issue_row(rec),
279 ));
280 }
281 rows.sort_by(|a, b| {
282 a.0.cmp(&b.0)
283 .then_with(|| a.1.cmp(&b.1))
284 .then_with(|| a.2.cmp(&b.2))
285 });
286 let mut out: Vec<IssueRow> = rows.into_iter().map(|r| r.3).collect();
287 let offset = q.offset.unwrap_or(0);
288 if offset >= out.len() {
289 out.clear();
290 } else if offset > 0 {
291 out = out.split_off(offset);
292 }
293 if let Some(limit) = q.limit {
294 out.truncate(limit);
295 }
296 Ok(out)
297}
298
299struct OrderingIndex<'a> {
301 by_id: HashMap<&'a str, &'a IssueRec>,
302 children: HashMap<&'a str, Vec<&'a IssueRec>>,
303}
304
305impl<'a> OrderingIndex<'a> {
306 fn new(issues: &'a [IssueRec]) -> Self {
307 let mut by_id = HashMap::with_capacity(issues.len());
308 let mut children: HashMap<&str, Vec<&IssueRec>> = HashMap::new();
309 for rec in issues {
310 by_id.insert(rec.heading.id.as_str(), rec);
311 if let Some(parent) = rec.heading.parent() {
312 children.entry(parent).or_default().push(rec);
313 }
314 }
315 Self { by_id, children }
316 }
317}
318
319fn ordered_sibling_holds(rec: &IssueRec, index: &OrderingIndex<'_>) -> bool {
320 if crate::org::org_property_is_set(&rec.heading.properties, "NOBLOCKING") {
321 return false;
322 }
323 let Some(parent_id) = rec.heading.parent() else {
324 return false;
325 };
326 let Some(parent) = index.by_id.get(parent_id) else {
327 return false;
328 };
329 if !crate::org::org_property_is_set(&parent.heading.properties, "ORDERED") {
330 return false;
331 }
332 index
333 .children
334 .get(parent_id)
335 .into_iter()
336 .flatten()
337 .any(|sib| {
338 sib.heading.id != rec.heading.id
339 && sib.heading.line_start < rec.heading.line_start
340 && sib.heading.state != "DONE"
341 && sib.heading.state != "CANCELLED"
342 })
343}
344
345fn list_query_matches(rec: &IssueRec, needle: &str) -> bool {
346 let h = &rec.heading;
347 let needle = needle.to_lowercase();
348 if h.id.to_lowercase().contains(&needle) || h.title.to_lowercase().contains(&needle) {
349 return true;
350 }
351 if rec.tag_settings.matches_query(&h.tags(), &needle) {
352 return true;
353 }
354 h.properties
355 .iter()
356 .any(|(k, v)| k.to_lowercase().contains(&needle) || v.to_lowercase().contains(&needle))
357}
358
359fn issue_row(rec: &IssueRec) -> IssueRow {
360 IssueRow {
361 id: rec.heading.id.clone(),
362 state: rec.heading.state.clone(),
363 priority: rec.heading.priority.to_string(),
364 title: rec.heading.title.clone(),
365 project: rec.project.clone(),
366 blocked_by: rec.heading.blocked_by(),
367 claimed_by: rec.heading.claimed_by().map(str::to_string),
368 claimed_at: rec.heading.claimed_at().map(str::to_string),
369 parent: rec.heading.parent().map(str::to_string),
370 }
371}
372
373fn issue_detail(rec: &IssueRec) -> IssueDetail {
374 IssueDetail {
375 id: rec.heading.id.clone(),
376 project: rec.project.clone(),
377 title: rec.heading.title.clone(),
378 state: rec.heading.state.clone(),
379 priority: rec.heading.priority.to_string(),
380 properties: rec.heading.properties.clone(),
381 org_tags: rec.heading.org_tags.clone(),
382 deeds: rec.heading.deeds(),
383 tags: rec.tag_settings.all_tags(&rec.heading.tags()),
384 blocked_by: rec.heading.blocked_by(),
385 parent: rec.heading.parent().map(str::to_string),
386 claimed_by: rec.heading.claimed_by().map(str::to_string),
387 claimed_at: rec.heading.claimed_at().map(str::to_string),
388 file: format!(
389 "{}:{}-{}",
390 rec.path.display(),
391 rec.heading.line_start,
392 rec.heading.line_end
393 ),
394 line_start: rec.heading.line_start,
395 line_end: rec.heading.line_end,
396 body: rec.heading.body.trim_end().to_string(),
397 logbook: rec
398 .heading
399 .logbook
400 .iter()
401 .map(|e| crate::views::LogbookLine {
402 timestamp: e.timestamp.clone(),
403 from_state: e.from_state.clone(),
404 to_state: e.to_state.clone(),
405 note: e.note.clone(),
406 raw: e.raw.clone(),
407 })
408 .collect(),
409 }
410}
411
412pub fn excerpt_from(rec: &IssueRec) -> Result<Excerpt> {
418 let content = fs::read_to_string(&rec.path)?;
419 let lines: Vec<&str> = content.lines().collect();
420 let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
421 let to = rec
422 .heading
423 .line_end
424 .min(lines.len())
425 .min(from + BODY_EXCERPT_MAX_LINES);
426 let mut text = lines[from..to].join("\n");
427 if text.len() > BODY_EXCERPT_MAX_CHARS {
428 text.truncate(BODY_EXCERPT_MAX_CHARS);
429 text.push_str("\n...");
430 }
431 let suppressed = match secret_marker(&text) {
432 Some(marker) => {
433 text = format!(
434 "(excerpt suppressed: {marker} looks like secret material; open {} directly)\n",
435 rec.path.display()
436 );
437 true
438 }
439 None => false,
440 };
441 Ok(Excerpt {
442 id: rec.heading.id.clone(),
443 file: rec.path.display().to_string(),
444 line_start: rec.heading.line_start,
445 line_end: rec.heading.line_end,
446 text,
447 suppressed,
448 })
449}
450
451pub fn org_text_from(rec: &IssueRec) -> Result<String> {
466 let content = fs::read_to_string(&rec.path)?;
467 let lines: Vec<&str> = content.lines().collect();
468 let from = rec.heading.line_start.saturating_sub(1).min(lines.len());
469 let to = rec.heading.line_end.min(lines.len()).max(from);
470 let text = lines[from..to].join("\n");
471 if let Some(marker) = secret_marker(&text) {
472 return Err(Error::Other(anyhow::anyhow!(
473 "{} looks like secret material; open {} directly",
474 marker,
475 rec.path.display()
476 )));
477 }
478 Ok(text)
479}
480
481pub(crate) fn format_body_excerpt(excerpt: &Excerpt) -> String {
483 if excerpt.suppressed {
484 return excerpt.text.clone();
485 }
486 let from = excerpt.line_start.saturating_sub(1);
487 let to = excerpt.line_end.min(from + BODY_EXCERPT_MAX_LINES);
488 format!(
489 "id: {}\nfile: {}:{}-{}\n--- excerpt (lines {}-{}) ---\n{}\n",
490 excerpt.id,
491 excerpt.file,
492 excerpt.line_start,
493 excerpt.line_end,
494 from + 1,
495 to,
496 excerpt.text
497 )
498}
499
500pub(crate) fn secret_marker(excerpt: &str) -> Option<&'static str> {
507 let lower = excerpt.to_lowercase();
508 if lower.contains("-----begin") && lower.contains("private key") {
510 return Some("a private key block");
511 }
512 for token in [
513 "private_key",
514 "secret_key",
515 "client_secret",
516 "access_token",
517 "refresh_token",
518 "bearer ",
519 "authorization:",
520 "aws_secret_access_key",
521 "begin rsa",
522 "begin openssh",
523 "begin pgp private",
524 ] {
525 if lower.contains(token) {
526 return Some("a credential keyword");
527 }
528 }
529 for line in lower.lines() {
535 let Some((name, value)) = line.split_once(['=', ':']) else {
536 continue;
537 };
538 let name = name
539 .trim()
540 .trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
541 let value = value.trim().trim_matches(['"', '\'']);
542 if value.len() < 12 || value.contains(char::is_whitespace) {
543 continue;
544 }
545 if ["password", "passwd", "api_key", "apikey", "token", "secret"]
546 .iter()
547 .any(|needle| name.ends_with(needle))
548 {
549 return Some("an assignment to a credential name");
550 }
551 }
552 for word in excerpt.split(|c: char| c.is_whitespace() || c == '"' || c == '\'') {
556 let word = word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-');
557 if word.len() < 12 {
558 continue;
559 }
560 for prefix in [
561 "ghp_",
562 "gho_",
563 "ghs_",
564 "github_pat_",
565 "xoxb-",
566 "xoxp-",
567 "xoxa-",
568 "xoxs-",
569 "sk-",
570 "AKIA",
571 "ASIA",
572 "glpat-",
573 ] {
574 if word.starts_with(prefix) {
575 return Some("a vendor token prefix");
576 }
577 }
578 }
579 None
580}
581
582pub fn search_hits_from(issues: &[IssueRec], query: &str, limit: usize) -> Result<Vec<SearchHit>> {
588 let needle = query.to_lowercase();
589 let mut hits: Vec<(char, String, String, SearchHit)> = Vec::new();
590 for rec in issues {
591 let h = &rec.heading;
592 if !search_haystack(rec).to_lowercase().contains(&needle)
593 && !rec.tag_settings.matches_query(&h.tags(), &needle)
594 {
595 continue;
596 }
597 hits.push((
598 h.priority,
599 h.state.clone(),
600 h.id.clone(),
601 SearchHit {
602 id: h.id.clone(),
603 project: rec.project.clone(),
604 state: h.state.clone(),
605 priority: h.priority.to_string(),
606 title: h.title.clone(),
607 snippet: search_snippet(rec, &needle),
608 },
609 ));
610 }
611 hits.sort_by(|a, b| {
612 a.0.cmp(&b.0)
613 .then_with(|| a.1.cmp(&b.1))
614 .then_with(|| a.2.cmp(&b.2))
615 });
616 hits.truncate(limit);
617 Ok(hits.into_iter().map(|h| h.3).collect())
618}
619
620fn search_haystack(rec: &IssueRec) -> String {
621 let h = &rec.heading;
622 let mut hay = String::new();
623 hay.push_str(&h.id);
624 hay.push(' ');
625 hay.push_str(&h.title);
626 hay.push(' ');
627 for (k, v) in &h.properties {
628 hay.push_str(k);
629 hay.push(':');
630 hay.push_str(v);
631 hay.push(' ');
632 }
633 for tag in rec.tag_settings.all_tags(&h.tags()) {
634 hay.push_str(&tag);
635 hay.push(' ');
636 }
637 hay.push_str(&h.body);
638 hay
639}
640
641fn search_snippet(rec: &IssueRec, needle: &str) -> String {
642 let h = &rec.heading;
643 let mut candidates = vec![h.id.clone(), h.title.clone()];
644 for (k, v) in &h.properties {
645 candidates.push(format!("{k}:{v}"));
646 }
647 candidates.extend(rec.tag_settings.all_tags(&h.tags()));
648 candidates.extend(h.body.lines().map(str::to_string));
649 let found = candidates
650 .into_iter()
651 .find(|line| line.to_lowercase().contains(needle))
652 .unwrap_or_else(|| h.title.clone());
653 const CAP: usize = 160;
654 if found.chars().count() > CAP {
655 let mut cut: String = found.chars().take(CAP).collect();
656 cut.push_str("...");
657 cut
658 } else {
659 found
660 }
661}
662
663pub fn claims_from(
669 issues: &[IssueRec],
670 holder: Option<&str>,
671 project: Option<&str>,
672) -> Result<Vec<ClaimRow>> {
673 let today = Local::now().date_naive();
674 let mut rows: Vec<(String, ClaimRow)> = Vec::new();
675 for rec in issues {
676 if !project_selected(&rec.project, project) {
677 continue;
678 }
679 let Some(who) = rec.heading.claimed_by() else {
680 continue;
681 };
682 if let Some(filter) = holder
683 && who != filter
684 {
685 continue;
686 }
687 let age = rec
688 .heading
689 .claimed_at()
690 .and_then(parse_org_date)
691 .map(|d| (today - d).num_days())
692 .unwrap_or(-1);
693 rows.push((
694 rec.heading.claimed_at().unwrap_or("").to_string(),
695 ClaimRow {
696 id: rec.heading.id.clone(),
697 project: rec.project.clone(),
698 state: rec.heading.state.clone(),
699 priority: rec.heading.priority.to_string(),
700 holder: Some(who.to_string()),
701 claimed_at: rec.heading.claimed_at().map(str::to_string),
702 age_days: age,
703 title: rec.heading.title.clone(),
704 },
705 ));
706 }
707 rows.sort_by(|a, b| a.0.cmp(&b.0));
708 Ok(rows.into_iter().map(|r| r.1).collect())
709}
710
711pub fn agenda_rows_from(
717 issues: &[IssueRec],
718 days: i64,
719 project: Option<&str>,
720) -> Result<Vec<AgendaRow>> {
721 let today = Local::now().date_naive();
722 let horizon = today + chrono::Duration::days(days);
723 let mut rows: Vec<(chrono::NaiveDate, char, AgendaRow)> = Vec::new();
724 for rec in issues {
725 if !project_selected(&rec.project, project) {
726 continue;
727 }
728 let h = &rec.heading;
729 if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
730 continue;
731 }
732 for (kind_ch, kind, value) in [
733 ('D', "deadline", h.deadline()),
734 ('S', "scheduled", h.scheduled()),
735 ] {
736 let Some(parsed) = value.and_then(parse_org_date) else {
737 continue;
738 };
739 if parsed > horizon {
740 continue;
741 }
742 let delta = (parsed - today).num_days();
743 rows.push((
744 parsed,
745 kind_ch,
746 AgendaRow {
747 date: parsed.to_string(),
748 kind: kind.to_string(),
749 overdue_days: if delta < 0 { -delta } else { 0 },
750 id: h.id.clone(),
751 project: rec.project.clone(),
752 state: h.state.clone(),
753 priority: h.priority.to_string(),
754 title: h.title.clone(),
755 },
756 ));
757 }
758 }
759 rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.id.cmp(&b.2.id)));
760 Ok(rows.into_iter().map(|r| r.2).collect())
761}
762
763pub fn tree_from(issues: &[IssueRec], id: &str) -> Result<TreeNode> {
769 if !issues.iter().any(|r| r.heading.id == id) {
770 return Err(Error::IssueNotFound { id: id.to_string() });
771 }
772 let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
773 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
774 for rec in issues {
775 by_id.insert(rec.heading.id.as_str(), &rec.heading);
776 if let Some(parent) = rec.heading.parent() {
777 children
778 .entry(parent)
779 .or_default()
780 .push(rec.heading.id.as_str());
781 }
782 }
783 for kids in children.values_mut() {
784 kids.sort_unstable();
785 }
786 Ok(build_tree(id, &by_id, &children, &mut HashSet::new()))
787}
788
789fn build_tree<'a>(
790 id: &'a str,
791 by_id: &HashMap<&'a str, &'a IssueHeading>,
792 children: &HashMap<&'a str, Vec<&'a str>>,
793 seen: &mut HashSet<&'a str>,
794) -> TreeNode {
795 if !seen.insert(id) {
796 return TreeNode {
797 id: id.to_string(),
798 state: String::new(),
799 title: String::new(),
800 children: Vec::new(),
801 blocked_by: Vec::new(),
802 };
803 }
804 let Some(h) = by_id.get(id) else {
805 return TreeNode {
806 id: id.to_string(),
807 state: String::new(),
808 title: String::new(),
809 children: Vec::new(),
810 blocked_by: Vec::new(),
811 };
812 };
813 let kids = children
814 .get(id)
815 .into_iter()
816 .flatten()
817 .map(|kid| build_tree(kid, by_id, children, seen))
818 .collect();
819 TreeNode {
820 id: h.id.clone(),
821 state: h.state.clone(),
822 title: h.title.clone(),
823 children: kids,
824 blocked_by: h.blocked_by(),
825 }
826}
827
828pub fn children_from(issues: &[IssueRec], parent_id: &str) -> Result<Vec<WalkHit>> {
834 let mut rows: Vec<(char, String, String, WalkHit)> = Vec::new();
835 for rec in issues {
836 if rec.heading.parent() == Some(parent_id) {
837 rows.push((
838 rec.heading.priority,
839 rec.heading.state.clone(),
840 rec.heading.id.clone(),
841 walk_hit(rec, "child"),
842 ));
843 }
844 }
845 if rows.is_empty() && !known_issue_id(issues, parent_id) {
846 return Err(Error::IssueNotFound {
847 id: parent_id.to_string(),
848 });
849 }
850 rows.sort_by(|a, b| {
851 a.0.cmp(&b.0)
852 .then_with(|| a.1.cmp(&b.1))
853 .then_with(|| a.2.cmp(&b.2))
854 });
855 Ok(rows.into_iter().map(|r| r.3).collect())
856}
857
858enum WalkKind {
859 Ancestors,
860 Impact,
861}
862
863fn walk_from(issues: &[IssueRec], id: &str, depth: usize, kind: WalkKind) -> Result<Vec<WalkHit>> {
864 let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
865 let walked = match kind {
866 WalkKind::Ancestors => graph.ancestors(id, depth)?,
867 WalkKind::Impact => graph.descendants(id, depth)?,
868 };
869 let relation = match kind {
870 WalkKind::Ancestors => "ancestor",
871 WalkKind::Impact => "descendant",
872 };
873 Ok(walked
874 .into_iter()
875 .filter_map(|(_distance, other)| {
876 issues
877 .iter()
878 .find(|r| r.heading.id == other)
879 .map(|r| walk_hit(r, relation))
880 })
881 .collect())
882}
883
884pub fn recall_from(issues: &[IssueRec], id: &str, depth: usize, excerpts: bool) -> Result<Recall> {
902 let rec = issues
903 .iter()
904 .find(|r| r.heading.id == id)
905 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
906
907 let mut plan: Vec<WalkHit> = Vec::new();
908 let mut seen: HashSet<&str> = HashSet::from([id]);
909 let mut at = rec.heading.parent();
910 while let Some(parent) = at {
914 if !seen.insert(parent) {
915 break;
916 }
917 match issues.iter().find(|r| r.heading.id == parent) {
918 Some(prec) => {
919 plan.push(walk_hit(prec, "plan"));
920 at = prec.heading.parent();
921 }
922 None => {
923 plan.push(WalkHit {
929 id: parent.to_string(),
930 project: String::new(),
931 state: String::new(),
932 title: "(a heading outside the tracker)".to_string(),
933 relation: "plan".to_string(),
934 });
935 break;
936 }
937 }
938 }
939 plan.reverse();
940
941 let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
942 let mut walked = graph.ancestors(id, depth)?;
943 walked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
946 let mut inputs: Vec<RecallInput> = Vec::new();
947 for (distance, other) in walked {
948 let Some(orec) = issues.iter().find(|r| r.heading.id == other) else {
949 continue;
950 };
951 let relation = if distance == 1 {
952 "blocked-by".to_string()
953 } else {
954 format!("blocked-by:{distance}")
955 };
956 inputs.push(recall_input(orec, &relation, excerpts));
957 }
958 if let Some(origin) = crate::props::get(&rec.heading.properties, crate::props::DISCOVERED_FROM)
961 && !inputs.iter().any(|i| i.id == origin)
962 && let Some(orec) = issues.iter().find(|r| r.heading.id == origin)
963 {
964 inputs.push(recall_input(orec, "discovered-from", excerpts));
965 }
966
967 Ok(Recall {
968 id: rec.heading.id.clone(),
969 project: rec.project.clone(),
970 state: rec.heading.state.clone(),
971 title: rec.heading.title.clone(),
972 plan,
973 inputs,
974 produced: rec.heading.deeds(),
975 body: rec.heading.body.trim_end().to_string(),
976 })
977}
978
979fn recall_input(rec: &IssueRec, relation: &str, excerpts: bool) -> RecallInput {
980 RecallInput {
981 id: rec.heading.id.clone(),
982 project: rec.project.clone(),
983 state: rec.heading.state.clone(),
984 title: rec.heading.title.clone(),
985 relation: relation.to_string(),
986 deeds: rec.heading.deeds(),
987 excerpt: excerpts
992 .then(|| excerpt_from(rec).ok().map(|e| e.text))
993 .flatten(),
994 last_note: rec
998 .heading
999 .logbook
1000 .iter()
1001 .filter(|entry| !entry.is_bookkeeping())
1002 .find_map(|entry| entry.note.clone()),
1003 }
1004}
1005
1006pub fn backlinks_from(issues: &[IssueRec], target_id: &str) -> Result<Vec<WalkHit>> {
1019 let mut out = Vec::new();
1020 if !known_issue_id(issues, target_id) && crate::ops::is_deed_accession(target_id) {
1021 for rec in issues {
1022 if rec.heading.deeds().iter().any(|cited| cited == target_id) {
1023 out.push(walk_hit(rec, "cites"));
1024 } else if rec.heading.body.contains(target_id) {
1025 out.push(walk_hit(rec, "body mention"));
1026 }
1027 }
1028 return Ok(out);
1032 }
1033 for rec in issues {
1034 if rec.heading.id == target_id {
1035 continue;
1036 }
1037 let mut hit = false;
1038 if rec.heading.blocked_by().iter().any(|b| b == target_id) {
1039 out.push(walk_hit(rec, "blocked-by"));
1040 hit = true;
1041 }
1042 if rec.heading.parent() == Some(target_id) {
1043 out.push(walk_hit(rec, "parent"));
1044 hit = true;
1045 }
1046 if rec
1047 .heading
1048 .properties
1049 .get("DISCOVERED_FROM")
1050 .map(String::as_str)
1051 == Some(target_id)
1052 {
1053 out.push(walk_hit(rec, "discovered-from"));
1054 hit = true;
1055 }
1056 if rec.heading.properties.get("PIVOTED_TO").map(String::as_str) == Some(target_id) {
1057 out.push(walk_hit(rec, "pivoted-to"));
1058 hit = true;
1059 }
1060 if !hit && rec.heading.body.contains(target_id) {
1061 out.push(walk_hit(rec, "body mention"));
1062 }
1063 }
1064 if out.is_empty() && !known_issue_id(issues, target_id) {
1065 return Err(Error::IssueNotFound {
1066 id: target_id.to_string(),
1067 });
1068 }
1069 Ok(out)
1070}
1071
1072pub fn tree_text_from(issues: &[IssueRec], id: &str, format: &str) -> Result<String> {
1079 if !issues.iter().any(|r| r.heading.id == id) {
1080 return Err(Error::IssueNotFound { id: id.to_string() });
1081 }
1082 let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
1083 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1084 let mut blockers: HashMap<&str, Vec<String>> = HashMap::new();
1085 for rec in issues {
1086 by_id.insert(rec.heading.id.as_str(), &rec.heading);
1087 if let Some(parent) = rec.heading.parent() {
1088 children
1089 .entry(parent)
1090 .or_default()
1091 .push(rec.heading.id.as_str());
1092 }
1093 let blocked = rec.heading.blocked_by();
1094 if !blocked.is_empty() {
1095 blockers.insert(rec.heading.id.as_str(), blocked);
1096 }
1097 }
1098 for kids in children.values_mut() {
1099 kids.sort_unstable();
1100 }
1101 let mut out = String::new();
1102 match format {
1103 "ascii" | "text" => tree_ascii_from(
1104 id,
1105 0,
1106 &by_id,
1107 &children,
1108 &blockers,
1109 &mut HashSet::new(),
1110 &mut out,
1111 ),
1112 "dot" => tree_dot_from(id, &by_id, &children, &blockers, &mut out),
1113 other => {
1114 return Err(Error::Other(anyhow::anyhow!(
1115 "unknown format {other:?}; allowed: ascii, dot"
1116 )));
1117 }
1118 }
1119 Ok(out)
1120}
1121
1122fn tree_ascii_from<'a>(
1123 id: &'a str,
1124 depth: usize,
1125 by_id: &HashMap<&str, &IssueHeading>,
1126 children: &HashMap<&str, Vec<&'a str>>,
1127 blockers: &'a HashMap<&str, Vec<String>>,
1128 seen: &mut HashSet<&'a str>,
1129 out: &mut String,
1130) {
1131 use std::fmt::Write as _;
1132 if !seen.insert(id) {
1133 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
1134 return;
1135 }
1136 let Some(h) = by_id.get(id) else {
1137 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
1138 return;
1139 };
1140 let _ = writeln!(
1141 out,
1142 "{}{id} {:<9} [#{}] {}",
1143 " ".repeat(depth),
1144 h.state,
1145 h.priority,
1146 h.title
1147 );
1148 if let Some(blocked) = blockers.get(id) {
1149 for blocker in blocked {
1150 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
1151 }
1152 }
1153 if let Some(kids) = children.get(id) {
1154 for kid in kids {
1155 tree_ascii_from(kid, depth + 1, by_id, children, blockers, seen, out);
1156 }
1157 }
1158}
1159
1160fn tree_dot_from<'a>(
1161 root_id: &'a str,
1162 by_id: &HashMap<&str, &IssueHeading>,
1163 children: &HashMap<&str, Vec<&'a str>>,
1164 blockers: &'a HashMap<&str, Vec<String>>,
1165 out: &mut String,
1166) {
1167 use std::fmt::Write as _;
1168 let _ = writeln!(out, "digraph vissue_tree {{");
1169 let _ = writeln!(out, " rankdir=LR;");
1170 let _ = writeln!(
1171 out,
1172 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1173 );
1174 let mut visited: HashSet<&str> = HashSet::new();
1175 let mut stack = vec![root_id];
1176 while let Some(id) = stack.pop() {
1177 if !visited.insert(id) {
1178 continue;
1179 }
1180 if let Some(h) = by_id.get(id) {
1181 let _ = writeln!(
1182 out,
1183 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
1184 dot_quoted(&h.id),
1185 dot_quoted(&h.title),
1186 dot_quoted(&h.state),
1187 dot_quoted(&h.priority.to_string())
1188 );
1189 if let Some(kids) = children.get(id) {
1190 for kid in kids {
1191 let _ = writeln!(
1192 out,
1193 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
1194 dot_quoted(&h.id),
1195 dot_quoted(kid)
1196 );
1197 stack.push(kid);
1198 }
1199 }
1200 if let Some(blocked) = blockers.get(id) {
1201 for b in blocked {
1202 let _ = writeln!(
1203 out,
1204 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1205 dot_quoted(b),
1206 dot_quoted(&h.id)
1207 );
1208 stack.push(b.as_str());
1209 }
1210 }
1211 }
1212 }
1213 let _ = writeln!(out, "}}");
1214}
1215
1216fn dot_quoted(text: &str) -> String {
1217 text.replace('\\', "\\\\")
1218 .replace('"', "\\\"")
1219 .replace('\n', "\\n")
1220 .replace('\r', "")
1221}
1222
1223fn known_issue_id(issues: &[IssueRec], id: &str) -> bool {
1224 issues.iter().any(|r| r.heading.id == id)
1225}
1226
1227fn walk_hit(rec: &IssueRec, relation: &str) -> WalkHit {
1228 WalkHit {
1229 id: rec.heading.id.clone(),
1230 project: rec.project.clone(),
1231 state: rec.heading.state.clone(),
1232 title: rec.heading.title.clone(),
1233 relation: relation.to_string(),
1234 }
1235}