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 deadline: rec.heading.deadline().map(str::to_string),
1015 scheduled: rec.heading.scheduled().map(str::to_string),
1016 plan,
1017 inputs,
1018 produced: rec.heading.deeds(),
1019 body: rec.heading.body.trim_end().to_string(),
1020 })
1021}
1022
1023fn recall_input(rec: &IssueRec, relation: &str, excerpts: bool) -> RecallInput {
1024 RecallInput {
1025 id: rec.heading.id.clone(),
1026 project: rec.project.clone(),
1027 state: rec.heading.state.clone(),
1028 title: rec.heading.title.clone(),
1029 relation: relation.to_string(),
1030 deeds: rec.heading.deeds(),
1031 excerpt: excerpts
1036 .then(|| excerpt_from(rec).ok().map(|e| e.text))
1037 .flatten(),
1038 last_note: rec
1042 .heading
1043 .logbook
1044 .iter()
1045 .filter(|entry| !entry.is_bookkeeping())
1046 .find_map(|entry| entry.note.clone()),
1047 }
1048}
1049
1050pub fn backlinks_from(issues: &[IssueRec], target_id: &str) -> Result<Vec<WalkHit>> {
1063 let mut out = Vec::new();
1064 if !known_issue_id(issues, target_id) && crate::ops::is_deed_accession(target_id) {
1065 for rec in issues {
1066 if rec.heading.deeds().iter().any(|cited| cited == target_id) {
1067 out.push(walk_hit(rec, "cites"));
1068 } else if rec.heading.body.contains(target_id) {
1069 out.push(walk_hit(rec, "body mention"));
1070 }
1071 }
1072 return Ok(out);
1076 }
1077 for rec in issues {
1078 if rec.heading.id == target_id {
1079 continue;
1080 }
1081 let mut hit = false;
1082 if rec.heading.blocked_by().iter().any(|b| b == target_id) {
1083 out.push(walk_hit(rec, "blocked-by"));
1084 hit = true;
1085 }
1086 if rec.heading.parent() == Some(target_id) {
1087 out.push(walk_hit(rec, "parent"));
1088 hit = true;
1089 }
1090 if rec
1091 .heading
1092 .properties
1093 .get("DISCOVERED_FROM")
1094 .map(String::as_str)
1095 == Some(target_id)
1096 {
1097 out.push(walk_hit(rec, "discovered-from"));
1098 hit = true;
1099 }
1100 if rec.heading.properties.get("PIVOTED_TO").map(String::as_str) == Some(target_id) {
1101 out.push(walk_hit(rec, "pivoted-to"));
1102 hit = true;
1103 }
1104 if !hit && rec.heading.body.contains(target_id) {
1105 out.push(walk_hit(rec, "body mention"));
1106 }
1107 }
1108 if out.is_empty() && !known_issue_id(issues, target_id) {
1109 return Err(Error::IssueNotFound {
1110 id: target_id.to_string(),
1111 });
1112 }
1113 Ok(out)
1114}
1115
1116pub fn tree_text_from(issues: &[IssueRec], id: &str, format: &str) -> Result<String> {
1123 if !issues.iter().any(|r| r.heading.id == id) {
1124 return Err(Error::IssueNotFound { id: id.to_string() });
1125 }
1126 let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
1127 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1128 let mut blockers: HashMap<&str, Vec<String>> = HashMap::new();
1129 for rec in issues {
1130 by_id.insert(rec.heading.id.as_str(), &rec.heading);
1131 if let Some(parent) = rec.heading.parent() {
1132 children
1133 .entry(parent)
1134 .or_default()
1135 .push(rec.heading.id.as_str());
1136 }
1137 let blocked = rec.heading.blocked_by();
1138 if !blocked.is_empty() {
1139 blockers.insert(rec.heading.id.as_str(), blocked);
1140 }
1141 }
1142 for kids in children.values_mut() {
1143 kids.sort_unstable();
1144 }
1145 let mut out = String::new();
1146 match format {
1147 "ascii" | "text" => tree_ascii_from(
1148 id,
1149 0,
1150 &by_id,
1151 &children,
1152 &blockers,
1153 &mut HashSet::new(),
1154 &mut out,
1155 ),
1156 "dot" => tree_dot_from(id, &by_id, &children, &blockers, &mut out),
1157 other => {
1158 return Err(Error::Other(anyhow::anyhow!(
1159 "unknown format {other:?}; allowed: ascii, dot"
1160 )));
1161 }
1162 }
1163 Ok(out)
1164}
1165
1166fn tree_ascii_from<'a>(
1167 id: &'a str,
1168 depth: usize,
1169 by_id: &HashMap<&str, &IssueHeading>,
1170 children: &HashMap<&str, Vec<&'a str>>,
1171 blockers: &'a HashMap<&str, Vec<String>>,
1172 seen: &mut HashSet<&'a str>,
1173 out: &mut String,
1174) {
1175 use std::fmt::Write as _;
1176 if !seen.insert(id) {
1177 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
1178 return;
1179 }
1180 let Some(h) = by_id.get(id) else {
1181 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
1182 return;
1183 };
1184 let _ = writeln!(
1185 out,
1186 "{}{id} {:<9} [#{}] {}",
1187 " ".repeat(depth),
1188 h.state,
1189 h.priority,
1190 h.title
1191 );
1192 if let Some(blocked) = blockers.get(id) {
1193 for blocker in blocked {
1194 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
1195 }
1196 }
1197 if let Some(kids) = children.get(id) {
1198 for kid in kids {
1199 tree_ascii_from(kid, depth + 1, by_id, children, blockers, seen, out);
1200 }
1201 }
1202}
1203
1204fn tree_dot_from<'a>(
1205 root_id: &'a str,
1206 by_id: &HashMap<&str, &IssueHeading>,
1207 children: &HashMap<&str, Vec<&'a str>>,
1208 blockers: &'a HashMap<&str, Vec<String>>,
1209 out: &mut String,
1210) {
1211 use std::fmt::Write as _;
1212 let _ = writeln!(out, "digraph vissue_tree {{");
1213 let _ = writeln!(out, " rankdir=LR;");
1214 let _ = writeln!(
1215 out,
1216 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1217 );
1218 let mut visited: HashSet<&str> = HashSet::new();
1219 let mut stack = vec![root_id];
1220 while let Some(id) = stack.pop() {
1221 if !visited.insert(id) {
1222 continue;
1223 }
1224 if let Some(h) = by_id.get(id) {
1225 let _ = writeln!(
1226 out,
1227 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
1228 dot_quoted(&h.id),
1229 dot_quoted(&h.title),
1230 dot_quoted(&h.state),
1231 dot_quoted(&h.priority.to_string())
1232 );
1233 if let Some(kids) = children.get(id) {
1234 for kid in kids {
1235 let _ = writeln!(
1236 out,
1237 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
1238 dot_quoted(&h.id),
1239 dot_quoted(kid)
1240 );
1241 stack.push(kid);
1242 }
1243 }
1244 if let Some(blocked) = blockers.get(id) {
1245 for b in blocked {
1246 let _ = writeln!(
1247 out,
1248 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1249 dot_quoted(b),
1250 dot_quoted(&h.id)
1251 );
1252 stack.push(b.as_str());
1253 }
1254 }
1255 }
1256 }
1257 let _ = writeln!(out, "}}");
1258}
1259
1260fn dot_quoted(text: &str) -> String {
1261 text.replace('\\', "\\\\")
1262 .replace('"', "\\\"")
1263 .replace('\n', "\\n")
1264 .replace('\r', "")
1265}
1266
1267fn known_issue_id(issues: &[IssueRec], id: &str) -> bool {
1268 issues.iter().any(|r| r.heading.id == id)
1269}
1270
1271fn walk_hit(rec: &IssueRec, relation: &str) -> WalkHit {
1272 WalkHit {
1273 id: rec.heading.id.clone(),
1274 project: rec.project.clone(),
1275 state: rec.heading.state.clone(),
1276 title: rec.heading.title.clone(),
1277 relation: relation.to_string(),
1278 }
1279}