1use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19
20use crate::analyzer::{LessonSeverity, PostMortemResult};
21
22pub const CANDIDATES_SECTION_START: &str = "<!-- edda:candidates:start -->";
23pub const CANDIDATES_SECTION_END: &str = "<!-- edda:candidates:end -->";
24
25const MIN_PROPOSAL_CONFIDENCE: f64 = 0.7;
27pub const MAX_CANDIDATES_PER_RUN: usize = 3;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DoctrineCandidate {
33 pub key: String,
35 pub name: String,
36 pub maxim: String,
37 pub session_id: String,
38 pub trigger: String,
39 pub evidence: Vec<String>,
40 pub created_at: String,
41}
42
43pub fn resolve_incubation_path(env_val: Option<&str>, cwd: &Path) -> Option<PathBuf> {
46 let raw = env_val?.trim();
47 if raw.is_empty() {
48 return None;
49 }
50 let p = Path::new(raw);
51 Some(if p.is_absolute() {
52 p.to_path_buf()
53 } else {
54 cwd.join(p)
55 })
56}
57
58pub fn select_candidates(result: &PostMortemResult, max: usize) -> Vec<DoctrineCandidate> {
61 let mut out: Vec<DoctrineCandidate> = Vec::new();
62
63 for lesson in &result.lessons {
64 if out.len() >= max {
65 break;
66 }
67 if lesson.severity != LessonSeverity::High {
68 continue;
69 }
70 let key = provenance_key(&lesson.source_trigger, &lesson.text);
71 out.push(DoctrineCandidate {
72 name: format!("{} ({})", lesson.source_trigger, &key[..6]),
73 maxim: lesson.text.clone(),
74 session_id: result.session_id.clone(),
75 trigger: lesson.source_trigger.clone(),
76 evidence: if lesson.tags.is_empty() {
77 vec!["(stats-level; event-id refs are v2)".to_string()]
78 } else {
79 lesson.tags.clone()
80 },
81 created_at: result.analyzed_at.clone(),
82 key,
83 });
84 }
85
86 for proposal in &result.rule_proposals {
87 if out.len() >= max {
88 break;
89 }
90 if proposal.confidence < MIN_PROPOSAL_CONFIDENCE {
91 continue;
92 }
93 let maxim = format!("{} (when: {})", proposal.action, proposal.trigger);
94 let key = provenance_key(&proposal.trigger, &proposal.action);
95 out.push(DoctrineCandidate {
96 name: format!("{} ({})", proposal.trigger, &key[..6]),
97 maxim,
98 session_id: result.session_id.clone(),
99 trigger: proposal.trigger.clone(),
100 evidence: proposal.evidence.clone(),
101 created_at: result.analyzed_at.clone(),
102 key,
103 });
104 }
105
106 out
107}
108
109pub fn sync_candidates_to_incubation(
119 path: &Path,
120 candidates: &[DoctrineCandidate],
121) -> io::Result<usize> {
122 sync_candidates_to_incubation_with_hints(path, candidates, &[])
123}
124
125pub fn sync_candidates_to_incubation_with_hints(
130 path: &Path,
131 candidates: &[DoctrineCandidate],
132 doctrine_entries: &[crate::sign_check::DoctrineEntry],
133) -> io::Result<usize> {
134 let existing = match fs::read_to_string(path) {
135 Ok(content) => Some(content),
136 Err(err) if err.kind() == io::ErrorKind::NotFound => None,
137 Err(err) => return Err(err),
138 };
139
140 let fresh: Vec<&DoctrineCandidate> = candidates
141 .iter()
142 .filter(|c| {
143 existing
144 .as_deref()
145 .map(|content| !content.contains(&format!("key: {}", c.key)))
146 .unwrap_or(true)
147 })
148 .collect();
149
150 if fresh.is_empty() {
151 return Ok(0);
152 }
153
154 let rendered: String = fresh
155 .iter()
156 .map(|c| {
157 let base = render_candidate(c);
158 let hint = crate::sign_check::render_related_hint(&c.maxim, doctrine_entries);
159 if hint.is_empty() {
160 base
161 } else {
162 let mut merged = base.trim_end_matches('\n').to_string();
164 merged.push_str(&hint);
165 merged.push('\n');
166 merged
167 }
168 })
169 .collect();
170
171 let new_content = match existing {
172 None => format!(
173 "# Incubation (Candidates)\n\n\
174 > Machine candidates below await human review. Promote, archive, or delete —\n\
175 > edda never writes main doctrine; signing is always a human edit.\n\n\
176 {start}\n{rendered}{end}\n",
177 start = CANDIDATES_SECTION_START,
178 end = CANDIDATES_SECTION_END,
179 rendered = rendered,
180 ),
181 Some(content) => {
182 match (
183 content.find(CANDIDATES_SECTION_START),
184 content.find(CANDIDATES_SECTION_END),
185 ) {
186 (Some(_), Some(end_pos)) => {
187 format!("{}{}{}", &content[..end_pos], rendered, &content[end_pos..])
190 }
191 _ => {
192 let sep = if content.ends_with('\n') { "" } else { "\n" };
194 format!(
195 "{content}{sep}\n{start}\n{rendered}{end}\n",
196 content = content,
197 sep = sep,
198 start = CANDIDATES_SECTION_START,
199 end = CANDIDATES_SECTION_END,
200 rendered = rendered,
201 )
202 }
203 }
204 }
205 };
206
207 fs::write(path, new_content)?;
208 Ok(fresh.len())
209}
210
211fn render_candidate(c: &DoctrineCandidate) -> String {
212 let evidence = if c.evidence.is_empty() {
213 "(stats-level; event-id refs are v2)".to_string()
214 } else {
215 c.evidence.join("; ")
216 };
217 format!(
218 "\n## Candidate: {name}\n\
219 <!-- key: {key} · session: {session} · trigger: {trigger} · at: {at} -->\n\n\
220 - **Source:** edda postmortem — session `{session}`, trigger `{trigger}`; evidence: {evidence}\n\
221 - **Why it may matter:** {maxim}\n\
222 - **Not promoted because:** machine-extracted (deterministic heuristics) — awaiting human sign-off\n\
223 - **Revisit after:** next maintenance sweep (30-day noise rule applies)\n",
224 name = c.name,
225 key = c.key,
226 session = c.session_id,
227 trigger = c.trigger,
228 at = c.created_at,
229 evidence = evidence,
230 maxim = c.maxim,
231 )
232}
233
234fn provenance_key(trigger: &str, text: &str) -> String {
235 let mut hasher = Sha256::new();
236 hasher.update(trigger.as_bytes());
237 hasher.update(b"|");
238 hasher.update(text.as_bytes());
239 hex::encode(hasher.finalize())[..12].to_string()
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::analyzer::{Lesson, LessonSeverity, PostMortemResult, RuleProposal};
246 use crate::rules::RuleCategory;
247 use crate::trigger::TriggerReason;
248
249 fn result_with(lessons: Vec<Lesson>, proposals: Vec<RuleProposal>) -> PostMortemResult {
250 PostMortemResult {
251 session_id: "sess-1".into(),
252 triggers: vec![TriggerReason::SessionFailures],
253 lessons,
254 rule_proposals: proposals,
255 analyzed_at: "2026-07-08T00:00:00Z".into(),
256 }
257 }
258
259 fn lesson(text: &str, severity: LessonSeverity) -> Lesson {
260 Lesson {
261 id: "lesson_x".into(),
262 text: text.into(),
263 severity,
264 tags: vec!["failure".into()],
265 source_trigger: "session_failures".into(),
266 }
267 }
268
269 fn proposal(action: &str, confidence: f64) -> RuleProposal {
270 RuleProposal {
271 trigger: "command_failure:npm".into(),
272 action: action.into(),
273 anchor_file: None,
274 category: RuleCategory::Workflow,
275 confidence,
276 evidence: vec!["Failed command: npm test".into()],
277 }
278 }
279
280 #[test]
281 fn resolve_path_none_or_empty_is_off() {
282 let cwd = Path::new("/repo");
283 assert_eq!(resolve_incubation_path(None, cwd), None);
284 assert_eq!(resolve_incubation_path(Some(""), cwd), None);
285 assert_eq!(resolve_incubation_path(Some(" "), cwd), None);
286 }
287
288 #[test]
289 fn resolve_path_relative_joins_cwd_absolute_kept() {
290 let cwd = Path::new("/repo");
291 assert_eq!(
292 resolve_incubation_path(Some("docs/incubation.md"), cwd),
293 Some(PathBuf::from("/repo").join("docs/incubation.md"))
294 );
295 let abs = if cfg!(windows) {
296 "C:\\x\\inc.md"
297 } else {
298 "/x/inc.md"
299 };
300 assert_eq!(
301 resolve_incubation_path(Some(abs), cwd),
302 Some(PathBuf::from(abs))
303 );
304 }
305
306 #[test]
307 fn select_takes_high_lessons_and_confident_proposals_capped() {
308 let result = result_with(
309 vec![
310 lesson("high one", LessonSeverity::High),
311 lesson("low one", LessonSeverity::Low),
312 lesson("high two", LessonSeverity::High),
313 ],
314 vec![proposal("confident", 0.8), proposal("weak", 0.5)],
315 );
316 let picked = select_candidates(&result, MAX_CANDIDATES_PER_RUN);
317 assert_eq!(picked.len(), 3);
318 assert!(picked[0].maxim.contains("high one"));
319 assert!(picked[1].maxim.contains("high two"));
320 assert!(picked[2].maxim.contains("confident"));
321 let again = select_candidates(&result, MAX_CANDIDATES_PER_RUN);
323 assert_eq!(picked[0].key, again[0].key);
324 }
325
326 #[test]
327 fn select_respects_hard_cap() {
328 let result = result_with(
329 (0..5)
330 .map(|i| lesson(&format!("high {i}"), LessonSeverity::High))
331 .collect(),
332 vec![],
333 );
334 assert_eq!(select_candidates(&result, 3).len(), 3);
335 }
336
337 #[test]
338 fn sync_creates_file_with_block_and_four_fields() {
339 let dir = tempfile::tempdir().unwrap();
340 let path = dir.path().join("incubation.md");
341 let result = result_with(
342 vec![lesson("watch the baseline", LessonSeverity::High)],
343 vec![],
344 );
345 let cands = select_candidates(&result, 3);
346
347 let n = sync_candidates_to_incubation(&path, &cands).unwrap();
348 assert_eq!(n, 1);
349 let content = fs::read_to_string(&path).unwrap();
350 assert!(content.contains(CANDIDATES_SECTION_START));
351 assert!(content.contains(CANDIDATES_SECTION_END));
352 assert!(content.contains("## Candidate:"));
353 assert!(content.contains("**Source:**"));
354 assert!(content.contains("**Why it may matter:** watch the baseline"));
355 assert!(content.contains("**Not promoted because:**"));
356 assert!(content.contains("**Revisit after:**"));
357 assert!(content.contains("session `sess-1`"));
358 }
359
360 #[test]
361 fn sync_is_idempotent_no_rewrite_on_second_run() {
362 let dir = tempfile::tempdir().unwrap();
363 let path = dir.path().join("incubation.md");
364 let result = result_with(vec![lesson("only once", LessonSeverity::High)], vec![]);
365 let cands = select_candidates(&result, 3);
366
367 assert_eq!(sync_candidates_to_incubation(&path, &cands).unwrap(), 1);
368 let first = fs::read_to_string(&path).unwrap();
369 assert_eq!(sync_candidates_to_incubation(&path, &cands).unwrap(), 0);
370 let second = fs::read_to_string(&path).unwrap();
371 assert_eq!(first, second, "no-op run must not rewrite the file");
372 }
373
374 #[test]
375 fn sync_preserves_bytes_outside_managed_block() {
376 let dir = tempfile::tempdir().unwrap();
377 let path = dir.path().join("incubation.md");
378 let prefix = "# My Incubation\n\nhand-written intro — do not touch\n\n";
379 let block = format!("{}\n{}\n", CANDIDATES_SECTION_START, CANDIDATES_SECTION_END);
380 let suffix = "\n## Recently promoted\n\n- 2026-07-01 — old note stays\n";
381 fs::write(&path, format!("{prefix}{block}{suffix}")).unwrap();
382
383 let result = result_with(vec![lesson("spliced in", LessonSeverity::High)], vec![]);
384 let n = sync_candidates_to_incubation(&path, &select_candidates(&result, 3)).unwrap();
385 assert_eq!(n, 1);
386
387 let content = fs::read_to_string(&path).unwrap();
388 assert!(content.starts_with(prefix), "bytes before block unchanged");
389 assert!(content.ends_with(suffix), "bytes after block unchanged");
390 assert!(content.contains("spliced in"));
391 let start = content.find(CANDIDATES_SECTION_START).unwrap();
392 let end = content.find(CANDIDATES_SECTION_END).unwrap();
393 let inside = &content[start..end];
394 assert!(
395 inside.contains("## Candidate:"),
396 "entry landed inside block"
397 );
398 }
399
400 #[test]
401 fn sync_with_empty_hints_matches_base_behavior_byte_for_byte() {
402 use tempfile::tempdir;
403 let dir_a = tempdir().unwrap();
404 let dir_b = tempdir().unwrap();
405 let result = result_with(vec![lesson("shared maxim", LessonSeverity::High)], vec![]);
406 let cands = select_candidates(&result, 3);
407
408 sync_candidates_to_incubation(&dir_a.path().join("i.md"), &cands).unwrap();
410 sync_candidates_to_incubation_with_hints(&dir_b.path().join("i.md"), &cands, &[]).unwrap();
412
413 let a = fs::read_to_string(dir_a.path().join("i.md")).unwrap();
414 let b = fs::read_to_string(dir_b.path().join("i.md")).unwrap();
415 assert_eq!(
416 a, b,
417 "empty hint slice = zero behavior change (opt-in guard)"
418 );
419 }
420
421 #[test]
422 fn sync_with_matching_hint_appends_related_block_inside_candidate() {
423 use crate::sign_check::parse_entries;
424 use tempfile::tempdir;
425 let dir = tempdir().unwrap();
426 let path = dir.path().join("incubation.md");
427 let entries = parse_entries("### FM-9: shared family topic\nbody\n", "failure-memory.md");
429 let result = result_with(
430 vec![lesson(
431 "shared family topic (surfaced)",
432 LessonSeverity::High,
433 )],
434 vec![],
435 );
436 let cands = select_candidates(&result, 3);
437 sync_candidates_to_incubation_with_hints(&path, &cands, &entries).unwrap();
438 let content = fs::read_to_string(&path).unwrap();
439 assert!(
440 content.contains("Related in doctrine"),
441 "hint block present"
442 );
443 assert!(content.contains("FM-9"), "specific matching heading named");
444 assert!(
445 content.contains("not judgment"),
446 "machine-hint red-line phrasing kept"
447 );
448 }
449
450 #[test]
451 fn sync_appends_block_when_file_has_none() {
452 let dir = tempfile::tempdir().unwrap();
453 let path = dir.path().join("incubation.md");
454 let original = "# Existing incubation without block\n";
455 fs::write(&path, original).unwrap();
456
457 let result = result_with(vec![lesson("new block", LessonSeverity::High)], vec![]);
458 sync_candidates_to_incubation(&path, &select_candidates(&result, 3)).unwrap();
459
460 let content = fs::read_to_string(&path).unwrap();
461 assert!(content.starts_with(original), "existing bytes untouched");
462 assert!(content.contains(CANDIDATES_SECTION_START));
463 }
464}