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(
721 issues: &[IssueRec],
722 days: i64,
723 project: Option<&str>,
724) -> Result<Vec<AgendaRow>> {
725 let today = Local::now().date_naive();
726 let horizon = today + chrono::Duration::days(days);
727 let mut rows: Vec<AgendaRow> = Vec::new();
728 for rec in issues {
729 if !project_selected(&rec.project, project) {
730 continue;
731 }
732 let h = &rec.heading;
733 if !READY_STATES.contains(&h.state.as_str()) && h.state != "BLOCKED" {
734 continue;
735 }
736 let deadline = h.deadline().and_then(parse_org_date);
737 let scheduled = h.scheduled().and_then(parse_org_date);
738 for (kind, parsed) in [("deadline", deadline), ("scheduled", scheduled)] {
739 let Some(parsed) = parsed else {
740 continue;
741 };
742 if parsed > horizon {
743 continue;
744 }
745 let delta = (parsed - today).num_days();
746 rows.push(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 for parsed in active_stamps_in(&h.title) {
758 if deadline == Some(parsed) || scheduled == Some(parsed) {
759 continue;
760 }
761 if parsed < today || parsed > horizon {
762 continue;
763 }
764 rows.push(AgendaRow {
765 date: parsed.to_string(),
766 kind: "appointment".to_string(),
767 overdue_days: 0,
768 id: h.id.clone(),
769 project: rec.project.clone(),
770 state: h.state.clone(),
771 priority: h.priority.to_string(),
772 title: h.title.clone(),
773 });
774 }
775 }
776 rows.sort_by(|a, b| {
777 agenda_kind_rank(&a.kind)
778 .cmp(&agenda_kind_rank(&b.kind))
779 .then(b.overdue_days.cmp(&a.overdue_days))
780 .then(a.date.cmp(&b.date))
781 .then(a.id.cmp(&b.id))
782 });
783 Ok(rows)
784}
785
786fn agenda_kind_rank(kind: &str) -> u8 {
787 match kind {
788 "deadline" => 0,
789 "scheduled" => 1,
790 _ => 2,
791 }
792}
793
794fn active_stamps_in(text: &str) -> Vec<chrono::NaiveDate> {
796 let mut out = Vec::new();
797 let bytes = text.as_bytes();
798 let mut i = 0;
799 while i + 11 <= bytes.len() {
800 if bytes[i] == b'<'
801 && let Ok(slice) = std::str::from_utf8(&bytes[i + 1..i + 11])
802 && let Ok(date) = chrono::NaiveDate::parse_from_str(slice, "%Y-%m-%d")
803 {
804 out.push(date);
805 i += 11;
806 continue;
807 }
808 i += 1;
809 }
810 out
811}
812
813pub fn tree_from(issues: &[IssueRec], id: &str) -> Result<TreeNode> {
819 if !issues.iter().any(|r| r.heading.id == id) {
820 return Err(Error::IssueNotFound { id: id.to_string() });
821 }
822 let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
823 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
824 for rec in issues {
825 by_id.insert(rec.heading.id.as_str(), &rec.heading);
826 if let Some(parent) = rec.heading.parent() {
827 children
828 .entry(parent)
829 .or_default()
830 .push(rec.heading.id.as_str());
831 }
832 }
833 for kids in children.values_mut() {
834 kids.sort_unstable();
835 }
836 Ok(build_tree(id, &by_id, &children, &mut HashSet::new()))
837}
838
839fn build_tree<'a>(
840 id: &'a str,
841 by_id: &HashMap<&'a str, &'a IssueHeading>,
842 children: &HashMap<&'a str, Vec<&'a str>>,
843 seen: &mut HashSet<&'a str>,
844) -> TreeNode {
845 if !seen.insert(id) {
846 return TreeNode {
847 id: id.to_string(),
848 state: String::new(),
849 title: String::new(),
850 children: Vec::new(),
851 blocked_by: Vec::new(),
852 };
853 }
854 let Some(h) = by_id.get(id) else {
855 return TreeNode {
856 id: id.to_string(),
857 state: String::new(),
858 title: String::new(),
859 children: Vec::new(),
860 blocked_by: Vec::new(),
861 };
862 };
863 let kids = children
864 .get(id)
865 .into_iter()
866 .flatten()
867 .map(|kid| build_tree(kid, by_id, children, seen))
868 .collect();
869 TreeNode {
870 id: h.id.clone(),
871 state: h.state.clone(),
872 title: h.title.clone(),
873 children: kids,
874 blocked_by: h.blocked_by(),
875 }
876}
877
878pub fn children_from(issues: &[IssueRec], parent_id: &str) -> Result<Vec<WalkHit>> {
884 let mut rows: Vec<(char, String, String, WalkHit)> = Vec::new();
885 for rec in issues {
886 if rec.heading.parent() == Some(parent_id) {
887 rows.push((
888 rec.heading.priority,
889 rec.heading.state.clone(),
890 rec.heading.id.clone(),
891 walk_hit(rec, "child"),
892 ));
893 }
894 }
895 if rows.is_empty() && !known_issue_id(issues, parent_id) {
896 return Err(Error::IssueNotFound {
897 id: parent_id.to_string(),
898 });
899 }
900 rows.sort_by(|a, b| {
901 a.0.cmp(&b.0)
902 .then_with(|| a.1.cmp(&b.1))
903 .then_with(|| a.2.cmp(&b.2))
904 });
905 Ok(rows.into_iter().map(|r| r.3).collect())
906}
907
908enum WalkKind {
909 Ancestors,
910 Impact,
911}
912
913fn walk_from(issues: &[IssueRec], id: &str, depth: usize, kind: WalkKind) -> Result<Vec<WalkHit>> {
914 let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
915 let walked = match kind {
916 WalkKind::Ancestors => graph.ancestors(id, depth)?,
917 WalkKind::Impact => graph.descendants(id, depth)?,
918 };
919 let relation = match kind {
920 WalkKind::Ancestors => "ancestor",
921 WalkKind::Impact => "descendant",
922 };
923 Ok(walked
924 .into_iter()
925 .filter_map(|(_distance, other)| {
926 issues
927 .iter()
928 .find(|r| r.heading.id == other)
929 .map(|r| walk_hit(r, relation))
930 })
931 .collect())
932}
933
934pub fn recall_from(issues: &[IssueRec], id: &str, depth: usize, excerpts: bool) -> Result<Recall> {
952 let rec = issues
953 .iter()
954 .find(|r| r.heading.id == id)
955 .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?;
956
957 let mut plan: Vec<WalkHit> = Vec::new();
958 let mut seen: HashSet<&str> = HashSet::from([id]);
959 let mut at = rec.heading.parent();
960 while let Some(parent) = at {
964 if !seen.insert(parent) {
965 break;
966 }
967 match issues.iter().find(|r| r.heading.id == parent) {
968 Some(prec) => {
969 plan.push(walk_hit(prec, "plan"));
970 at = prec.heading.parent();
971 }
972 None => {
973 plan.push(WalkHit {
979 id: parent.to_string(),
980 project: String::new(),
981 state: String::new(),
982 title: "(a heading outside the tracker)".to_string(),
983 relation: "plan".to_string(),
984 });
985 break;
986 }
987 }
988 }
989 plan.reverse();
990
991 let graph = DependencyGraph::from_headings(issues.iter().map(|r| &r.heading))?;
992 let mut walked = graph.ancestors(id, depth)?;
993 walked.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
996 let mut inputs: Vec<RecallInput> = Vec::new();
997 for (distance, other) in walked {
998 let Some(orec) = issues.iter().find(|r| r.heading.id == other) else {
999 continue;
1000 };
1001 let relation = if distance == 1 {
1002 "blocked-by".to_string()
1003 } else {
1004 format!("blocked-by:{distance}")
1005 };
1006 inputs.push(recall_input(orec, &relation, excerpts));
1007 }
1008 if let Some(origin) = crate::props::get(&rec.heading.properties, crate::props::DISCOVERED_FROM)
1011 && !inputs.iter().any(|i| i.id == origin)
1012 && let Some(orec) = issues.iter().find(|r| r.heading.id == origin)
1013 {
1014 inputs.push(recall_input(orec, "discovered-from", excerpts));
1015 }
1016
1017 Ok(Recall {
1018 id: rec.heading.id.clone(),
1019 project: rec.project.clone(),
1020 state: rec.heading.state.clone(),
1021 title: rec.heading.title.clone(),
1022 plan,
1023 inputs,
1024 produced: rec.heading.deeds(),
1025 body: rec.heading.body.trim_end().to_string(),
1026 })
1027}
1028
1029fn recall_input(rec: &IssueRec, relation: &str, excerpts: bool) -> RecallInput {
1030 RecallInput {
1031 id: rec.heading.id.clone(),
1032 project: rec.project.clone(),
1033 state: rec.heading.state.clone(),
1034 title: rec.heading.title.clone(),
1035 relation: relation.to_string(),
1036 deeds: rec.heading.deeds(),
1037 excerpt: excerpts
1042 .then(|| excerpt_from(rec).ok().map(|e| e.text))
1043 .flatten(),
1044 last_note: rec
1048 .heading
1049 .logbook
1050 .iter()
1051 .filter(|entry| !entry.is_bookkeeping())
1052 .find_map(|entry| entry.note.clone()),
1053 }
1054}
1055
1056pub fn backlinks_from(issues: &[IssueRec], target_id: &str) -> Result<Vec<WalkHit>> {
1069 let mut out = Vec::new();
1070 if !known_issue_id(issues, target_id) && crate::ops::is_deed_accession(target_id) {
1071 for rec in issues {
1072 if rec.heading.deeds().iter().any(|cited| cited == target_id) {
1073 out.push(walk_hit(rec, "cites"));
1074 } else if rec.heading.body.contains(target_id) {
1075 out.push(walk_hit(rec, "body mention"));
1076 }
1077 }
1078 return Ok(out);
1082 }
1083 for rec in issues {
1084 if rec.heading.id == target_id {
1085 continue;
1086 }
1087 let mut hit = false;
1088 if rec.heading.blocked_by().iter().any(|b| b == target_id) {
1089 out.push(walk_hit(rec, "blocked-by"));
1090 hit = true;
1091 }
1092 if rec.heading.parent() == Some(target_id) {
1093 out.push(walk_hit(rec, "parent"));
1094 hit = true;
1095 }
1096 if rec
1097 .heading
1098 .properties
1099 .get("DISCOVERED_FROM")
1100 .map(String::as_str)
1101 == Some(target_id)
1102 {
1103 out.push(walk_hit(rec, "discovered-from"));
1104 hit = true;
1105 }
1106 if rec.heading.properties.get("PIVOTED_TO").map(String::as_str) == Some(target_id) {
1107 out.push(walk_hit(rec, "pivoted-to"));
1108 hit = true;
1109 }
1110 if !hit && rec.heading.body.contains(target_id) {
1111 out.push(walk_hit(rec, "body mention"));
1112 }
1113 }
1114 if out.is_empty() && !known_issue_id(issues, target_id) {
1115 return Err(Error::IssueNotFound {
1116 id: target_id.to_string(),
1117 });
1118 }
1119 Ok(out)
1120}
1121
1122pub fn tree_text_from(issues: &[IssueRec], id: &str, format: &str) -> Result<String> {
1129 if !issues.iter().any(|r| r.heading.id == id) {
1130 return Err(Error::IssueNotFound { id: id.to_string() });
1131 }
1132 let mut by_id: HashMap<&str, &IssueHeading> = HashMap::new();
1133 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1134 let mut blockers: HashMap<&str, Vec<String>> = HashMap::new();
1135 for rec in issues {
1136 by_id.insert(rec.heading.id.as_str(), &rec.heading);
1137 if let Some(parent) = rec.heading.parent() {
1138 children
1139 .entry(parent)
1140 .or_default()
1141 .push(rec.heading.id.as_str());
1142 }
1143 let blocked = rec.heading.blocked_by();
1144 if !blocked.is_empty() {
1145 blockers.insert(rec.heading.id.as_str(), blocked);
1146 }
1147 }
1148 for kids in children.values_mut() {
1149 kids.sort_unstable();
1150 }
1151 let mut out = String::new();
1152 match format {
1153 "ascii" | "text" => tree_ascii_from(
1154 id,
1155 0,
1156 &by_id,
1157 &children,
1158 &blockers,
1159 &mut HashSet::new(),
1160 &mut out,
1161 ),
1162 "dot" => tree_dot_from(id, &by_id, &children, &blockers, &mut out),
1163 other => {
1164 return Err(Error::Other(anyhow::anyhow!(
1165 "unknown format {other:?}; allowed: ascii, dot"
1166 )));
1167 }
1168 }
1169 Ok(out)
1170}
1171
1172fn tree_ascii_from<'a>(
1173 id: &'a str,
1174 depth: usize,
1175 by_id: &HashMap<&str, &IssueHeading>,
1176 children: &HashMap<&str, Vec<&'a str>>,
1177 blockers: &'a HashMap<&str, Vec<String>>,
1178 seen: &mut HashSet<&'a str>,
1179 out: &mut String,
1180) {
1181 use std::fmt::Write as _;
1182 if !seen.insert(id) {
1183 let _ = writeln!(out, "{}{id} (cycle, stopping)", " ".repeat(depth));
1184 return;
1185 }
1186 let Some(h) = by_id.get(id) else {
1187 let _ = writeln!(out, "{}{id} (missing)", " ".repeat(depth));
1188 return;
1189 };
1190 let _ = writeln!(
1191 out,
1192 "{}{id} {:<9} [#{}] {}",
1193 " ".repeat(depth),
1194 h.state,
1195 h.priority,
1196 h.title
1197 );
1198 if let Some(blocked) = blockers.get(id) {
1199 for blocker in blocked {
1200 let _ = writeln!(out, "{}* blocked-by {blocker}", " ".repeat(depth + 1));
1201 }
1202 }
1203 if let Some(kids) = children.get(id) {
1204 for kid in kids {
1205 tree_ascii_from(kid, depth + 1, by_id, children, blockers, seen, out);
1206 }
1207 }
1208}
1209
1210fn tree_dot_from<'a>(
1211 root_id: &'a str,
1212 by_id: &HashMap<&str, &IssueHeading>,
1213 children: &HashMap<&str, Vec<&'a str>>,
1214 blockers: &'a HashMap<&str, Vec<String>>,
1215 out: &mut String,
1216) {
1217 use std::fmt::Write as _;
1218 let _ = writeln!(out, "digraph vissue_tree {{");
1219 let _ = writeln!(out, " rankdir=LR;");
1220 let _ = writeln!(
1221 out,
1222 " node [shape=box, fontname=\"Jost\", style=filled, fillcolor=\"#E0F2F1\"];"
1223 );
1224 let mut visited: HashSet<&str> = HashSet::new();
1225 let mut stack = vec![root_id];
1226 while let Some(id) = stack.pop() {
1227 if !visited.insert(id) {
1228 continue;
1229 }
1230 if let Some(h) = by_id.get(id) {
1231 let _ = writeln!(
1232 out,
1233 " \"{}\" [label=\"{}\\n{} [#{}]\"];",
1234 dot_quoted(&h.id),
1235 dot_quoted(&h.title),
1236 dot_quoted(&h.state),
1237 dot_quoted(&h.priority.to_string())
1238 );
1239 if let Some(kids) = children.get(id) {
1240 for kid in kids {
1241 let _ = writeln!(
1242 out,
1243 " \"{}\" -> \"{}\" [color=\"#00897B\"];",
1244 dot_quoted(&h.id),
1245 dot_quoted(kid)
1246 );
1247 stack.push(kid);
1248 }
1249 }
1250 if let Some(blocked) = blockers.get(id) {
1251 for b in blocked {
1252 let _ = writeln!(
1253 out,
1254 " \"{}\" -> \"{}\" [style=dashed, color=\"#FF7043\", label=\"blocks\"];",
1255 dot_quoted(b),
1256 dot_quoted(&h.id)
1257 );
1258 stack.push(b.as_str());
1259 }
1260 }
1261 }
1262 }
1263 let _ = writeln!(out, "}}");
1264}
1265
1266fn dot_quoted(text: &str) -> String {
1267 text.replace('\\', "\\\\")
1268 .replace('"', "\\\"")
1269 .replace('\n', "\\n")
1270 .replace('\r', "")
1271}
1272
1273fn known_issue_id(issues: &[IssueRec], id: &str) -> bool {
1274 issues.iter().any(|r| r.heading.id == id)
1275}
1276
1277fn walk_hit(rec: &IssueRec, relation: &str) -> WalkHit {
1278 WalkHit {
1279 id: rec.heading.id.clone(),
1280 project: rec.project.clone(),
1281 state: rec.heading.state.clone(),
1282 title: rec.heading.title.clone(),
1283 relation: relation.to_string(),
1284 }
1285}