Skip to main content

borg/notes/
links.rs

1//! Decision → fix links (regress.rs finds them): the record a link leaves in the collective's
2//! `reasoning.jsonl` and the `borg fixed in:` / `borg fixes:` sections of a borg note.
3//!
4//! A record's `commit` is the **decision**, its `fix` the fix: the mesh answers `Ask` for a link by
5//! either side, so `borg notes fetch` with the decision or the fix brings it along. Its free text (tasks,
6//! subject) is redacted before it gets here.
7
8use serde::{Deserialize, Serialize};
9
10/// `kind` of a link record in reasoning.jsonl.
11pub const KIND: &str = "regress";
12pub const FIX: &str = "fix";
13pub const REVISITED: &str = "revisited";
14
15/// A note lists at most this many commits per section. Display: a git note stays readable.
16const MAX_LISTED: usize = 10;
17
18const FIXED_IN: &str = "\n\nborg fixed in:\n";
19const FIXES: &str = "\n\nborg fixes:\n";
20
21/// One side of a link as a note lists it: the other commit, its session and task (first line).
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct LinkRef {
24    pub commit: String,
25    pub session: String,
26    pub task: String,
27    /// YYYY-MM-DD (committer date).
28    pub date: String,
29    /// `FIX` or `REVISITED`.
30    pub strength: String,
31}
32
33/// A commit as a link record describes it.
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Side {
36    pub session: String,
37    pub task: String,
38    #[serde(default, skip_serializing_if = "String::is_empty")]
39    pub subject: String,
40    pub date: String,
41    /// Committer time, epoch seconds; 0 in records from before it was kept.
42    #[serde(default, skip_serializing_if = "is_zero")]
43    pub secs: i64,
44}
45
46fn is_zero(v: &i64) -> bool {
47    *v == 0
48}
49
50/// Lines of one file at the fix's parent, as inclusive ranges.
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Touched {
53    pub path: String,
54    pub lines: Vec<(u32, u32)>,
55}
56
57/// A link record: `fix` changed lines `commit` (the decision) wrote.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Link {
60    pub kind: String,
61    pub commit: String,
62    pub fix: String,
63    pub repo: String,
64    pub collective: String,
65    pub strength: String,
66    pub signals: Vec<String>,
67    pub decision: Side,
68    pub fixed: Side,
69    pub files: Vec<Touched>,
70    pub lines: usize,
71    pub age_days: u32,
72    pub ms: i64,
73}
74
75impl Link {
76    #[must_use]
77    pub fn strong(&self) -> bool {
78        self.strength == FIX
79    }
80}
81
82/// The link records among reasoning.jsonl's lines (`text`).
83#[must_use]
84pub fn records(text: &str) -> Vec<Link> {
85    text.lines().filter(|l| l.contains("\"regress\"")).filter_map(|l| serde_json::from_str::<Link>(l).ok()).filter(|l| l.kind == KIND).collect()
86}
87
88/// The two sides of a link as notes list them: (on the decision, on the fix).
89#[must_use]
90pub fn refs(l: &Link) -> (LinkRef, LinkRef) {
91    let fixed_by = LinkRef {
92        commit: l.fix.clone(),
93        session: l.fixed.session.clone(),
94        task: l.fixed.task.clone(),
95        date: l.fixed.date.clone(),
96        strength: l.strength.clone(),
97    };
98    let fixes = LinkRef {
99        commit: l.commit.clone(),
100        session: l.decision.session.clone(),
101        task: l.decision.task.clone(),
102        date: l.decision.date.clone(),
103        strength: l.strength.clone(),
104    };
105    (fixed_by, fixes)
106}
107
108/// `list` with `r` added (unless its commit is listed), at most `MAX_LISTED`: the oldest weak
109/// entries go first. Whether it was added.
110pub fn push_ref(list: &mut Vec<LinkRef>, r: LinkRef) -> bool {
111    if list.iter().any(|x| x.commit == r.commit) {
112        return false;
113    }
114    list.push(r);
115    while list.len() > MAX_LISTED {
116        let i = list.iter().position(|x| x.strength == REVISITED).unwrap_or(0);
117        list.remove(i);
118    }
119    true
120}
121
122/// A note's two lists, to add links to.
123pub struct Lists<'a> {
124    pub fixed_by: &'a mut Vec<LinkRef>,
125    pub fixes: &'a mut Vec<LinkRef>,
126}
127
128/// Adds the `records` naming `commit` (a note of `collective`) to its lists: as the decision under
129/// `fixed_by`, as the fix under `fixes`; a record of another collective never. Whether any was new.
130#[must_use]
131pub fn adopt(commit: &str, collective: &str, records: &[Link], lists: &mut Lists) -> bool {
132    let mut changed = false;
133    for l in records.iter().filter(|l| l.collective.is_empty() || l.collective == collective) {
134        let (by, fixes) = refs(l);
135        if l.commit == commit {
136            changed |= push_ref(lists.fixed_by, by);
137        }
138        if l.fix == commit {
139            changed |= push_ref(lists.fixes, fixes);
140        }
141    }
142    changed
143}
144
145fn line(l: &LinkRef) -> String {
146    format!("- {} {} {} session {}: {}\n", l.commit, l.date, l.strength, l.session, l.task)
147}
148
149/// The note text after the corrections line: both sections, each only when it lists something.
150pub fn sections(fixed_by: &[LinkRef], fixes: &[LinkRef]) -> String {
151    let mut s = String::new();
152    for (head, list) in [(FIXED_IN, fixed_by), (FIXES, fixes)] {
153        if !list.is_empty() {
154            s.push_str(&head[1..]);
155            s.extend(list.iter().map(line));
156        }
157    }
158    s
159}
160
161fn parse_line(l: &str) -> Option<LinkRef> {
162    let (commit, rest) = l.strip_prefix("- ")?.split_once(' ')?;
163    let (date, rest) = rest.split_once(' ')?;
164    let (strength, rest) = rest.split_once(' ')?;
165    let rest = rest.strip_prefix("session ")?;
166    // git strips the trailing space a line with an empty task ends in.
167    let (session, task) = rest.split_once(": ").or_else(|| Some((rest.strip_suffix(':')?, "")))?;
168    Some(LinkRef { commit: commit.into(), session: session.into(), task: task.trim().into(), date: date.into(), strength: strength.into() })
169}
170
171fn parse(list: &str) -> Vec<LinkRef> {
172    list.lines().filter_map(parse_line).collect()
173}
174
175/// `text` split at the last `head`: what comes before it, ending in a newline as the section's
176/// predecessor did before git collapsed the blank lines (an empty proposal), and the list after it.
177fn cut_at<'a>(text: &'a str, head: &str) -> (&'a str, Vec<LinkRef>) {
178    text.rfind(head).map_or((text, Vec::new()), |i| (&text[..=i], parse(&text[i + head.len()..])))
179}
180
181/// A note's text without its link sections, and the `fixed in` and `fixes` lists (the inverse of
182/// `sections`).
183#[must_use]
184pub fn split(text: &str) -> (&str, Vec<LinkRef>, Vec<LinkRef>) {
185    let (rest, fixes) = cut_at(text, FIXES);
186    let (rest, fixed_by) = cut_at(rest, FIXED_IN);
187    (rest, fixed_by, fixes)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    fn r(commit: &str, strength: &str) -> LinkRef {
195        LinkRef { commit: commit.into(), session: "s-7".into(), task: "fix the flaky sync: test".into(), date: "2026-10-12".into(), strength: strength.into() }
196    }
197
198    #[test]
199    fn listed_once_at_most_ten_dropping_old_weak_first() {
200        let mut list: Vec<LinkRef> = (0..10).map(|i| r(&format!("c{i}"), if i == 3 { REVISITED } else { FIX })).collect();
201        assert!(!push_ref(&mut list, r("c0", FIX)), "listed already");
202        assert!(push_ref(&mut list, r("new", FIX)));
203        assert_eq!(list.len(), 10);
204        assert!(!list.iter().any(|x| x.commit == "c3") && list.iter().any(|x| x.commit == "c0"));
205        assert!(push_ref(&mut list, r("newer", FIX)));
206        assert!(!list.iter().any(|x| x.commit == "c0"), "then the oldest");
207    }
208
209    #[test]
210    fn records_are_adopted_by_both_sides_of_their_collective_only() {
211        let rec = |commit: &str, fix: &str, collective: &str| Link {
212            kind: KIND.into(),
213            commit: commit.into(),
214            fix: fix.into(),
215            collective: collective.into(),
216            strength: FIX.into(),
217            ..Link::default()
218        };
219        let text = [rec("d", "f", "work"), rec("d", "g", "private"), rec("e", "d", "work")]
220            .iter()
221            .map(|l| serde_json::to_string(l).unwrap())
222            .chain(["{\"kind\":\"reasoning\",\"commit\":\"d\"}".to_string()])
223            .collect::<Vec<_>>()
224            .join("\n");
225        let records = records(&text);
226        assert_eq!(records.len(), 3);
227        let (mut by, mut fixes) = (Vec::new(), Vec::new());
228        assert!(adopt("d", "work", &records, &mut Lists { fixed_by: &mut by, fixes: &mut fixes }));
229        assert_eq!((by.iter().map(|x| x.commit.as_str()).collect::<Vec<_>>(), fixes[0].commit.as_str()), (vec!["f"], "e"));
230        assert!(!adopt("d", "work", &records, &mut Lists { fixed_by: &mut by, fixes: &mut fixes }), "once");
231    }
232
233    #[test]
234    fn sections_round_trip() {
235        let (by, fixes) = (vec![r("aa", FIX), r("bb", REVISITED)], vec![r("cc", FIX)]);
236        let text = format!("Last proposal:\nkeep it\n{}", sections(&by, &fixes));
237        assert!(text.contains("\n\nborg fixed in:\n- aa 2026-10-12 fix session s-7: fix the flaky sync: test\n"), "{text}");
238        assert_eq!(split(&text), ("Last proposal:\nkeep it\n", by.clone(), fixes.clone()));
239        assert_eq!(split("plain\ntext"), ("plain\ntext", vec![], vec![]));
240        let only_fixes = format!("x\n{}", sections(&[], &fixes));
241        assert_eq!(split(&only_fixes), ("x\n", vec![], fixes));
242        // As git stores it: an empty task loses its trailing space, an empty proposal a blank line.
243        let stored = "Last proposal:\n\nborg fixed in:\n- aa 2026-10-12 fix session s-7:\n";
244        let empty = LinkRef { task: String::new(), ..r("aa", FIX) };
245        assert_eq!(split(stored), ("Last proposal:\n", vec![empty], vec![]));
246    }
247}