1use serde::Serialize;
8
9use crate::notes::links::{self, FIX, Link, LinkRef};
10use crate::notes::{Excerpt, redact};
11
12const MAX_LINKS: usize = 3;
14use super::TASK_CAP;
15
16#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
18pub struct LinkLine {
19 pub sha: String,
20 pub short: String,
21 pub date: String,
22 pub strength: String,
24 pub task: String,
26}
27
28fn line(r: &LinkRef) -> LinkLine {
29 LinkLine {
30 sha: r.commit.clone(),
31 short: r.commit.chars().take(7).collect(),
32 date: r.date.clone(),
33 strength: r.strength.clone(),
34 task: redact(r.task.lines().next().unwrap_or_default().trim()),
35 }
36}
37
38fn merged<'a>(noted: &'a [LinkRef], stored: impl Iterator<Item = LinkRef> + 'a) -> Vec<LinkLine> {
39 let mut out: Vec<LinkLine> = noted.iter().map(line).collect();
40 for r in stored {
41 if !out.iter().any(|x| x.sha == r.commit) {
42 out.push(line(&r));
43 }
44 }
45 out
46}
47
48#[must_use]
50pub fn of(sha: &str, noted: Option<&Excerpt>, stored: &[Link]) -> (Vec<LinkLine>, Vec<LinkLine>) {
51 let (by, fixes) = noted.map_or((&[][..], &[][..]), |e| (e.fixed_by.as_slice(), e.fixes.as_slice()));
52 let fixed_in = merged(by, stored.iter().filter(|l| l.commit == sha).map(|l| links::refs(l).0));
53 let fixes = merged(fixes, stored.iter().filter(|l| l.fix == sha).map(|l| links::refs(l).1));
54 (fixed_in, fixes)
55}
56
57pub fn compact(lines: &mut Vec<LinkLine>) {
59 lines.truncate(MAX_LINKS);
60 for l in lines {
61 if l.task.chars().count() > TASK_CAP {
62 l.task = format!("{}…", l.task.chars().take(TASK_CAP).collect::<String>());
63 }
64 }
65}
66
67fn verb(l: &LinkLine) -> &'static str {
68 if l.strength == FIX { "fixed" } else { "revisited" }
69}
70
71fn task(l: &LinkLine) -> String {
72 if l.task.is_empty() { String::new() } else { format!(" (task: {})", l.task) }
73}
74
75#[must_use]
77pub fn render(fixed_in: &[LinkLine], fixes: &[LinkLine]) -> String {
78 let later = fixed_in.iter().map(|l| format!(" → {} in {} {}{}\n", verb(l), l.short, l.date, task(l)));
79 let earlier = fixes.iter().map(|l| format!(" ← decided in {} {}{} → {} here\n", l.short, l.date, task(l), verb(l)));
80 later.chain(earlier).collect()
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use crate::notes::links::{REVISITED, Side};
87
88 fn r(commit: &str, strength: &str, task: &str) -> LinkRef {
89 LinkRef { commit: commit.into(), session: "s".into(), task: task.into(), date: "2026-10-12".into(), strength: strength.into() }
90 }
91
92 #[test]
93 fn note_and_store_merge_one_line_per_commit() {
94 let note = Excerpt { fixed_by: vec![r("aaaaaaaa1", FIX, "fix the race")], ..Excerpt::default() };
95 let stored = |commit: &str, fix: &str| Link {
96 commit: commit.into(),
97 fix: fix.into(),
98 strength: REVISITED.into(),
99 decision: Side { task: "decide".into(), date: "2026-09-30".into(), ..Side::default() },
100 fixed: Side { task: "rename token=sk-abcdefghijklmnopqrstuvwx".into(), date: "2026-10-14".into(), ..Side::default() },
101 ..Link::default()
102 };
103 let links = [stored("d", "aaaaaaaa1"), stored("d", "bbbbbbbb2"), stored("cccccccc3", "d")];
104 let (fixed_in, fixes) = of("d", Some(¬e), &links);
105 assert_eq!(fixed_in.iter().map(|l| (l.short.as_str(), l.strength.as_str())).collect::<Vec<_>>(), [("aaaaaaa", FIX), ("bbbbbbb", REVISITED)]);
106 assert!(!fixed_in[1].task.contains("sk-abc"), "redacted: {}", fixed_in[1].task);
107 assert_eq!((fixes.len(), fixes[0].task.as_str()), (1, "decide"));
108 let text = render(&fixed_in[..1], &fixes);
109 assert_eq!(text, " → fixed in aaaaaaa 2026-10-12 (task: fix the race)\n ← decided in ccccccc 2026-09-30 (task: decide) → revisited here\n");
110 let (later, earlier) = of("x", None, &links);
111 assert!(later.is_empty() && earlier.is_empty());
112 let mut many: Vec<LinkLine> = (0..5).map(|i| line(&r(&format!("c{i}"), FIX, &"t".repeat(300)))).collect();
113 compact(&mut many);
114 assert_eq!((many.len(), many[0].task.chars().count()), (3, TASK_CAP + 1));
115 }
116}