1use crate::analyzer::{Lesson, LessonSeverity};
21use serde::Deserialize;
22use std::collections::HashMap;
23use std::path::Path;
24use time::format_description::well_known::Rfc3339;
25use time::{Duration, OffsetDateTime};
26
27pub const RECURRENCE_WINDOW_DAYS: i64 = 30;
28pub const PREFIX_LEN: usize = 40;
34
35#[derive(Debug, Clone)]
37pub struct LedgerNote {
38 pub ts: String,
39 pub tag: String,
40 pub text: String,
41}
42
43pub fn analyze_recurring_scars(notes: &[LedgerNote], now: OffsetDateTime) -> Vec<Lesson> {
46 let cutoff = now - Duration::days(RECURRENCE_WINDOW_DAYS);
47 let mut by_family: HashMap<(String, String), Vec<&LedgerNote>> = HashMap::new();
48
49 for note in notes {
50 let Some(ts) = OffsetDateTime::parse(¬e.ts, &Rfc3339).ok() else {
51 continue;
52 };
53 if ts < cutoff {
54 continue;
55 }
56 if !is_scar_tag(¬e.tag) {
57 continue;
58 }
59 let prefix = normalize_prefix(¬e.text);
60 if prefix.is_empty() {
61 continue;
62 }
63 by_family
64 .entry((note.tag.clone(), prefix))
65 .or_default()
66 .push(note);
67 }
68
69 let mut lessons = Vec::new();
70 for ((tag, prefix), hits) in by_family {
71 if hits.len() < 2 {
72 continue;
73 }
74 let mut ordered = hits.clone();
76 ordered.sort_by(|a, b| b.ts.cmp(&a.ts));
77 let latest = &ordered[0];
78 let excerpt = excerpt_of(&latest.text);
79 let text = format!(
80 "Recurring {tag} ({n}x): \"{excerpt}\". Second occurrence of the same problem — havamal rule says a repeated scar is doctrine material.",
81 tag = tag,
82 n = hits.len(),
83 excerpt = excerpt,
84 );
85 lessons.push(Lesson {
86 id: new_lesson_id(&tag, &prefix),
87 text,
88 severity: LessonSeverity::High,
89 tags: vec![tag.clone(), "recurring".into()],
90 source_trigger: format!("recurring_{tag}"),
91 });
92 }
93
94 lessons.sort_by(|a, b| a.id.cmp(&b.id));
96 lessons
97}
98
99fn is_scar_tag(tag: &str) -> bool {
100 matches!(tag, "review" | "friction")
101}
102
103pub fn normalize_prefix(text: &str) -> String {
108 let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
109 let lower = first_line.to_lowercase();
110 let collapsed: String = lower.split_whitespace().collect::<Vec<_>>().join(" ");
111 let clause: String = collapsed
112 .chars()
113 .take_while(|c| !matches!(c, ';' | '.' | '。' | '—' | '~'))
114 .collect();
115 let clause = clause.replace(" - ", " "); let stripped = clause
117 .trim_end_matches(|c: char| c.is_ascii_punctuation() || c == '。' || c == '、')
118 .trim()
119 .to_string();
120 stripped.chars().take(PREFIX_LEN).collect()
121}
122
123fn excerpt_of(text: &str) -> String {
124 let first_line = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
125 let collapsed: String = first_line.split_whitespace().collect::<Vec<_>>().join(" ");
126 let limited: String = collapsed.chars().take(140).collect();
127 limited.replace('"', "'")
128}
129
130fn new_lesson_id(tag: &str, prefix: &str) -> String {
131 use sha2::{Digest, Sha256};
132 let mut hasher = Sha256::new();
133 hasher.update(tag.as_bytes());
134 hasher.update(b"|");
135 hasher.update(prefix.as_bytes());
136 format!("lesson_scar_{}", &hex::encode(hasher.finalize())[..12])
137}
138
139#[derive(Debug, Deserialize)]
141struct LogRow {
142 ts: String,
143 payload: Option<LogPayload>,
144}
145
146#[derive(Debug, Deserialize)]
147struct LogPayload {
148 #[serde(default)]
149 text: String,
150 #[serde(default)]
151 tags: Vec<String>,
152}
153
154pub fn read_ledger_notes(path: &Path) -> Vec<LedgerNote> {
158 let Ok(content) = std::fs::read_to_string(path) else {
159 return Vec::new();
160 };
161 let mut out = Vec::new();
162 for line in content.lines() {
163 if line.trim().is_empty() {
164 continue;
165 }
166 let Ok(row) = serde_json::from_str::<LogRow>(line) else {
167 continue;
168 };
169 let Some(payload) = row.payload else { continue };
170 for tag in &payload.tags {
171 if is_scar_tag(tag) {
172 out.push(LedgerNote {
173 ts: row.ts.clone(),
174 tag: tag.clone(),
175 text: payload.text.clone(),
176 });
177 }
178 }
179 }
180 out
181}
182
183pub fn notes_from_events(events: &[edda_core::types::Event]) -> Vec<LedgerNote> {
188 let mut out = Vec::new();
189 for event in events {
190 if event.event_type != "note" {
191 continue;
192 }
193 let text = event
194 .payload
195 .get("text")
196 .and_then(|v| v.as_str())
197 .unwrap_or("");
198 if text.is_empty() {
199 continue;
200 }
201 let tags = event
202 .payload
203 .get("tags")
204 .and_then(|v| v.as_array())
205 .cloned()
206 .unwrap_or_default();
207 for tag in tags {
208 let Some(tag_str) = tag.as_str() else {
209 continue;
210 };
211 if is_scar_tag(tag_str) {
212 out.push(LedgerNote {
213 ts: event.ts.clone(),
214 tag: tag_str.to_string(),
215 text: text.to_string(),
216 });
217 }
218 }
219 }
220 out
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn note(ts: &str, tag: &str, text: &str) -> LedgerNote {
228 LedgerNote {
229 ts: ts.into(),
230 tag: tag.into(),
231 text: text.into(),
232 }
233 }
234
235 fn now() -> OffsetDateTime {
236 OffsetDateTime::parse("2026-07-08T12:00:00Z", &Rfc3339).unwrap()
237 }
238
239 #[test]
240 fn single_occurrence_yields_no_lesson() {
241 let notes = vec![note(
242 "2026-07-08T01:00:00Z",
243 "friction",
244 "worktree has no .edda, executor cannot inject context",
245 )];
246 assert!(
247 analyze_recurring_scars(¬es, now()).is_empty(),
248 "one hit is not a scar (havamal 二犯規矩)"
249 );
250 }
251
252 #[test]
253 fn two_occurrences_same_prefix_yields_high_lesson_with_excerpt() {
254 let notes = vec![
255 note(
256 "2026-07-05T01:00:00Z",
257 "friction",
258 "Worktree has no .edda; executor cannot inject context on start",
259 ),
260 note(
261 "2026-07-08T01:00:00Z",
262 "friction",
263 "worktree has no .edda — executor context broken again in nested session",
264 ),
265 ];
266 let lessons = analyze_recurring_scars(¬es, now());
267 assert_eq!(lessons.len(), 1);
268 assert_eq!(lessons[0].severity, LessonSeverity::High);
269 assert!(lessons[0].tags.contains(&"friction".to_string()));
270 assert!(lessons[0].tags.contains(&"recurring".to_string()));
271 assert!(lessons[0].text.contains("2x"), "count in text");
272 assert!(
273 lessons[0]
274 .text
275 .to_lowercase()
276 .contains("worktree has no .edda"),
277 "candidate cites the scar text verbatim (not template)"
278 );
279 }
280
281 #[test]
282 fn out_of_window_occurrences_dont_count() {
283 let notes = vec![
284 note(
285 "2026-05-01T00:00:00Z",
286 "friction",
287 "old worktree issue in stale window",
288 ),
289 note(
290 "2026-07-08T00:00:00Z",
291 "friction",
292 "old worktree issue back in fresh window",
293 ),
294 ];
295 assert!(
296 analyze_recurring_scars(¬es, now()).is_empty(),
297 "one in-window hit only (窗外的不算)"
298 );
299 }
300
301 #[test]
302 fn non_scar_tags_ignored() {
303 let notes = vec![
304 note(
305 "2026-07-08T01:00:00Z",
306 "session",
307 "same prefix same day tag session",
308 ),
309 note(
310 "2026-07-08T02:00:00Z",
311 "session",
312 "same prefix same day tag session",
313 ),
314 ];
315 assert!(
316 analyze_recurring_scars(¬es, now()).is_empty(),
317 "session tag is not a scar family"
318 );
319 }
320
321 #[test]
322 fn prefix_normalization_matches_case_and_whitespace_variants() {
323 let notes = vec![
324 note(
325 "2026-07-08T01:00:00Z",
326 "review",
327 " Findings: dispatch layer authored discipline text\n\nmore detail",
328 ),
329 note(
330 "2026-07-08T02:00:00Z",
331 "review",
332 "FINDINGS: dispatch layer authored discipline text!",
333 ),
334 ];
335 let lessons = analyze_recurring_scars(¬es, now());
336 assert_eq!(
337 lessons.len(),
338 1,
339 "case+whitespace+punct differences collapse to one family"
340 );
341 }
342
343 #[test]
344 fn malformed_lines_and_missing_ts_skipped() {
345 let notes = vec![
346 note("garbage-ts", "friction", "hit a"),
347 note("2026-07-08T01:00:00Z", "friction", "hit b"),
348 note("2026-07-08T02:00:00Z", "friction", "hit b"),
349 ];
350 let lessons = analyze_recurring_scars(¬es, now());
351 assert_eq!(
352 lessons.len(),
353 1,
354 "garbage-ts note dropped, valid pair remains"
355 );
356 }
357}