1use serde::{Deserialize, Serialize};
9
10pub const KIND: &str = "regress";
12pub const FIX: &str = "fix";
13pub const REVISITED: &str = "revisited";
14
15const MAX_LISTED: usize = 10;
17
18const FIXED_IN: &str = "\n\nborg fixed in:\n";
19const FIXES: &str = "\n\nborg fixes:\n";
20
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct LinkRef {
24 pub commit: String,
25 pub session: String,
26 pub task: String,
27 pub date: String,
29 pub strength: String,
31}
32
33#[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Touched {
53 pub path: String,
54 pub lines: Vec<(u32, u32)>,
55}
56
57#[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#[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#[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
108pub 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
122pub struct Lists<'a> {
124 pub fixed_by: &'a mut Vec<LinkRef>,
125 pub fixes: &'a mut Vec<LinkRef>,
126}
127
128#[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
149pub 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 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
175fn 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#[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 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}