1use std::fmt::Write as _;
12use std::path::Path;
13
14use serde_json::Value;
15
16use crate::learn::{self, Episode, regret};
17use crate::notes::links::{Link, Touched};
18use crate::notes::regress_diff::Hunk;
19use crate::notes::{Excerpt, redact};
20
21const CAP: usize = 1500;
23
24#[derive(Debug, Clone, Default)]
26pub struct FixContext {
27 pub task: String,
29 pub email: String,
31 pub diff: String,
33 pub why: String,
35}
36
37#[must_use]
39pub fn id_of(l: &Link) -> String {
40 let short = |s: &str| s.chars().take(10).collect::<String>();
41 format!("R{}-{}", short(&l.commit), short(&l.fix))
42}
43
44fn cut(s: &str) -> String {
45 redact(s).chars().take(CAP).collect()
46}
47
48fn tail(s: &str) -> String {
49 let s = redact(s);
50 let n = s.chars().count();
51 s.chars().skip(n.saturating_sub(CAP)).collect()
52}
53
54fn overlaps(h: &Hunk, t: &Touched) -> bool {
55 h.path == t.path && h.removed.iter().any(|(n, _)| t.lines.iter().any(|&(a, b)| (a..=b).contains(n)))
56}
57
58#[must_use]
62pub fn diff_summary(hunks: &[Hunk], files: &[Touched]) -> String {
63 let (on, beside): (Vec<&Hunk>, Vec<&Hunk>) =
64 hunks.iter().filter(|h| files.iter().any(|t| t.path == h.path)).partition(|h| files.iter().any(|t| overlaps(h, t)));
65 let mut s = String::new();
66 for h in on.into_iter().chain(beside) {
67 let _ = writeln!(s, "{}", h.path);
68 for (_, l) in &h.removed {
69 let _ = writeln!(s, "- {}", l.trim_end());
70 }
71 for l in &h.added {
72 let _ = writeln!(s, "+ {}", l.trim_end());
73 }
74 }
75 cut(&s)
76}
77
78#[must_use]
80pub fn episode(l: &Link, decision: Option<&Excerpt>, fix: &FixContext) -> Episode {
81 let r = regret::Regret {
82 decision: l.commit.clone(),
83 decision_session: l.decision.session.clone(),
84 decision_task: cut(decision.map_or(l.decision.task.as_str(), |e| e.task.as_str())),
85 decision_proposal: tail(decision.map_or("", |e| e.proposal.as_str())),
86 fix: l.fix.clone(),
87 fix_subject: cut(&l.fixed.subject),
88 diff: cut(&fix.diff),
89 fix_why: tail(&fix.why),
90 signals: l.signals.clone(),
91 age_days: l.age_days,
92 decision_ms: l.decision.secs.saturating_mul(1000),
93 };
94 let correction = format!("Fixed later ({}d, {}): {}\n{}", r.age_days, r.signals.join(", "), r.fix_subject, r.diff);
95 Episode {
96 id: id_of(l),
97 ms: learn::now_ms(),
98 repo: l.repo.clone(),
99 session: l.fixed.session.clone(),
100 task: cut(&fix.task),
101 agent: r.decision_proposal.clone(),
102 rejected_tool: None,
103 correction: correction.trim_end().to_string(),
104 engineer: fix.email.trim().to_string(),
105 kind: regret::KIND.into(),
106 regret: Some(r),
107 }
108}
109
110#[must_use]
112pub fn known(store: &Path, id: &str) -> bool {
113 let text = std::fs::read_to_string(store.join("episodes.jsonl")).unwrap_or_default();
114 text.lines().filter(|l| l.contains(id)).filter_map(|l| serde_json::from_str::<Value>(l).ok()).any(|v| v["id"] == id)
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::notes::links::Side;
121
122 fn link() -> Link {
123 Link {
124 commit: "d".repeat(40),
125 fix: "f".repeat(40),
126 repo: "app".into(),
127 strength: "fix".into(),
128 signals: vec!["subject".into(), "tests".into()],
129 decision: Side { session: "s-1".into(), task: "first line".into(), ..Side::default() },
130 fixed: Side { session: "s-7".into(), subject: "fix: take the lock".into(), ..Side::default() },
131 files: vec![Touched { path: "src/lib.rs".into(), lines: vec![(2, 2)] }],
132 age_days: 3,
133 ..Link::default()
134 }
135 }
136
137 #[test]
138 fn a_strong_link_becomes_a_regret_of_the_fix_session() {
139 let decision = Excerpt {
140 task: "read the flag in b\nwithout a lock".into(),
141 proposal: "No lock needed. token=sk-abcdefghijklmnopqrstuvwx".into(),
142 ..Excerpt::default()
143 };
144 let fix = FixContext {
145 task: "make the flag safe".into(),
146 email: "bob@example.com\n".into(),
147 diff: "src/lib.rs\n- fn b()".into(),
148 why: "the sync test is flaky\nTook the lock; password=hunter22".into(),
149 };
150 let e = episode(&link(), Some(&decision), &fix);
151 assert_eq!((e.id.as_str(), e.session.as_str(), e.kind.as_str(), e.engineer.as_str()), ("Rdddddddddd-ffffffffff", "s-7", "regret", "bob@example.com"));
152 assert_eq!(e.correction, "Fixed later (3d, subject, tests): fix: take the lock\nsrc/lib.rs\n- fn b()");
153 let r = e.regret.unwrap();
154 assert_eq!((r.decision_task.as_str(), r.age_days), ("read the flag in b\nwithout a lock", 3));
155 assert!(r.decision_proposal.starts_with("No lock needed.") && !r.decision_proposal.contains("sk-abc"), "redacted: {}", r.decision_proposal);
156 assert!(r.fix_why.starts_with("the sync test is flaky") && !r.fix_why.contains("hunter22"), "{}", r.fix_why);
157 assert_eq!(r.decision_ms, 0, "a link from before committer times were kept");
158 assert_eq!(episode(&link(), None, &fix).regret.unwrap().decision_task, "first line");
160 let mut dated = link();
162 dated.decision.secs = 1_700_000_000;
163 assert_eq!(episode(&dated, None, &fix).regret.unwrap().decision_ms, 1_700_000_000_000);
164 }
165
166 #[test]
167 fn the_summary_keeps_the_decisions_files_its_lines_first() {
168 let h = |path: &str, at: u32| Hunk { path: path.into(), removed: vec![(at, format!("old {at}"))], added: vec![format!("new {at}")], ..Hunk::default() };
169 let files = [Touched { path: "src/lib.rs".into(), lines: vec![(2, 4)] }];
170 let s = diff_summary(&[h("src/lib.rs", 9), h("src/lib.rs", 3), h("src/other.rs", 3)], &files);
171 assert_eq!(s, "src/lib.rs\n- old 3\n+ new 3\nsrc/lib.rs\n- old 9\n+ new 9\n");
172 let big = Hunk { path: "src/lib.rs".into(), removed: vec![(2, "x".repeat(3000))], ..Hunk::default() };
173 assert_eq!(diff_summary(&[big], &files).chars().count(), CAP);
174 }
175}