Skip to main content

chronicle/annotate/
squash.rs

1use std::path::Path;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::chronicle_error::{IoSnafu, JsonSnafu};
7use crate::error::Result;
8use crate::git::GitOps;
9use crate::schema::v1::{
10    self, ContextLevel, CrossCuttingConcern, Provenance, ProvenanceOperation, RegionAnnotation,
11};
12use crate::schema::{self, v3};
13type Annotation = v1::Annotation;
14use snafu::ResultExt;
15
16/// Expiry time for pending-squash.json files, in seconds.
17const PENDING_SQUASH_EXPIRY_SECS: i64 = 60;
18
19/// Written to .git/chronicle/pending-squash.json by prepare-commit-msg.
20/// Consumed and deleted by post-commit.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PendingSquash {
23    pub source_commits: Vec<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub source_ref: Option<String>,
26    pub timestamp: DateTime<Utc>,
27}
28
29/// Context for squash synthesis, assembled before calling the agent.
30#[derive(Debug, Clone)]
31pub struct SquashSynthesisContext {
32    /// The squash commit's SHA.
33    pub squash_commit: String,
34    /// The squash commit's combined diff as text.
35    pub diff: String,
36    /// Annotations from source commits (those that had annotations).
37    pub source_annotations: Vec<Annotation>,
38    /// Commit messages from source commits: (sha, message).
39    pub source_messages: Vec<(String, String)>,
40    /// The squash commit's own commit message.
41    pub squash_message: String,
42}
43
44/// Context for amend migration, assembled before calling the agent.
45#[derive(Debug, Clone)]
46pub struct AmendMigrationContext {
47    /// The new (post-amend) commit SHA.
48    pub new_commit: String,
49    /// The new commit's diff (against its parent).
50    pub new_diff: String,
51    /// The old (pre-amend) annotation.
52    pub old_annotation: Annotation,
53    /// The new commit message.
54    pub new_message: String,
55}
56
57fn pending_squash_path(git_dir: &Path) -> std::path::PathBuf {
58    git_dir.join("chronicle").join("pending-squash.json")
59}
60
61/// Write pending-squash.json to .git/chronicle/.
62pub fn write_pending_squash(git_dir: &Path, pending: &PendingSquash) -> Result<()> {
63    let path = pending_squash_path(git_dir);
64    if let Some(parent) = path.parent() {
65        std::fs::create_dir_all(parent).context(IoSnafu)?;
66    }
67    let json = serde_json::to_string_pretty(pending).context(JsonSnafu)?;
68    std::fs::write(&path, json).context(IoSnafu)?;
69    Ok(())
70}
71
72/// Read pending-squash.json. Returns None if missing, stale, or invalid.
73/// Stale or invalid files are deleted with a warning.
74pub fn read_pending_squash(git_dir: &Path) -> Result<Option<PendingSquash>> {
75    let path = pending_squash_path(git_dir);
76    if !path.exists() {
77        return Ok(None);
78    }
79
80    let content = std::fs::read_to_string(&path).context(IoSnafu)?;
81    let pending: PendingSquash = match serde_json::from_str(&content) {
82        Ok(p) => p,
83        Err(e) => {
84            tracing::warn!("Invalid pending-squash.json, deleting: {e}");
85            let _ = std::fs::remove_file(&path);
86            return Ok(None);
87        }
88    };
89
90    let age = Utc::now() - pending.timestamp;
91    if age.num_seconds() > PENDING_SQUASH_EXPIRY_SECS {
92        tracing::warn!(
93            "Stale pending-squash.json ({}s old), deleting",
94            age.num_seconds()
95        );
96        std::fs::remove_file(&path).context(IoSnafu)?;
97        return Ok(None);
98    }
99
100    Ok(Some(pending))
101}
102
103/// Delete the pending-squash.json file.
104pub fn delete_pending_squash(git_dir: &Path) -> Result<()> {
105    let path = pending_squash_path(git_dir);
106    if path.exists() {
107        std::fs::remove_file(&path).context(IoSnafu)?;
108    }
109    Ok(())
110}
111
112/// Synthesize an annotation from multiple source annotations (squash merge).
113///
114/// This merges regions, combines cross-cutting concerns, and sets provenance.
115/// For MVP, this does not call the LLM — it performs a deterministic merge.
116/// A future version will pass SquashSynthesisContext to the writing agent.
117pub fn synthesize_squash_annotation(ctx: &SquashSynthesisContext) -> Annotation {
118    let mut all_regions: Vec<RegionAnnotation> = Vec::new();
119    let mut all_cross_cutting: Vec<CrossCuttingConcern> = Vec::new();
120    let mut source_shas: Vec<String> = Vec::new();
121    let has_annotations = !ctx.source_annotations.is_empty();
122
123    for ann in &ctx.source_annotations {
124        source_shas.push(ann.commit.clone());
125
126        // Merge regions: collect all, deduplicating by (file, ast_anchor.name)
127        for region in &ann.regions {
128            let already_exists = all_regions
129                .iter()
130                .any(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name);
131            if already_exists {
132                // Find existing and append reasoning
133                if let Some(existing) = all_regions
134                    .iter_mut()
135                    .find(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name)
136                {
137                    // Merge constraints (never drop)
138                    for constraint in &region.constraints {
139                        if !existing
140                            .constraints
141                            .iter()
142                            .any(|c| c.text == constraint.text)
143                        {
144                            existing.constraints.push(constraint.clone());
145                        }
146                    }
147                    // Merge semantic dependencies
148                    for dep in &region.semantic_dependencies {
149                        if !existing
150                            .semantic_dependencies
151                            .iter()
152                            .any(|d| d.file == dep.file && d.anchor == dep.anchor)
153                        {
154                            existing.semantic_dependencies.push(dep.clone());
155                        }
156                    }
157                    // Consolidate reasoning
158                    if let Some(new_reasoning) = &region.reasoning {
159                        if let Some(ref mut existing_reasoning) = existing.reasoning {
160                            existing_reasoning.push_str("\n\n");
161                            existing_reasoning.push_str(new_reasoning);
162                        } else {
163                            existing.reasoning = Some(new_reasoning.clone());
164                        }
165                    }
166                    // Update line range to encompass both
167                    existing.lines.start = existing.lines.start.min(region.lines.start);
168                    existing.lines.end = existing.lines.end.max(region.lines.end);
169                }
170            } else {
171                all_regions.push(region.clone());
172            }
173        }
174
175        // Merge cross-cutting concerns (deduplicate by description)
176        for cc in &ann.cross_cutting {
177            if !all_cross_cutting
178                .iter()
179                .any(|c| c.description == cc.description)
180            {
181                all_cross_cutting.push(cc.clone());
182            }
183        }
184    }
185
186    // Collect source SHAs from source_messages for any that didn't have annotations
187    for (sha, _) in &ctx.source_messages {
188        if !source_shas.contains(sha) {
189            source_shas.push(sha.clone());
190        }
191    }
192
193    let annotations_count = ctx.source_annotations.len();
194    let total_sources = ctx.source_messages.len();
195    let all_had_annotations = annotations_count == total_sources && total_sources > 0;
196
197    let synthesis_notes = if has_annotations {
198        Some(format!(
199            "Synthesized from {} commits ({} of {} had annotations).",
200            total_sources, annotations_count, total_sources,
201        ))
202    } else {
203        Some(format!(
204            "Synthesized from {} commits (none had annotations).",
205            total_sources,
206        ))
207    };
208
209    Annotation {
210        schema: "chronicle/v1".to_string(),
211        commit: ctx.squash_commit.clone(),
212        timestamp: Utc::now().to_rfc3339(),
213        task: None,
214        summary: ctx.squash_message.clone(),
215        context_level: if has_annotations {
216            ContextLevel::Enhanced
217        } else {
218            ContextLevel::Inferred
219        },
220        regions: all_regions,
221        cross_cutting: all_cross_cutting,
222        provenance: Provenance {
223            operation: ProvenanceOperation::Squash,
224            derived_from: source_shas,
225            original_annotations_preserved: all_had_annotations,
226            synthesis_notes,
227        },
228    }
229}
230
231/// Migrate an annotation from a pre-amend commit to a post-amend commit.
232///
233/// If the diff is empty (message-only amend), copies the annotation unchanged
234/// except for updating the commit SHA and provenance.
235pub fn migrate_amend_annotation(ctx: &AmendMigrationContext) -> Annotation {
236    let mut new_annotation = ctx.old_annotation.clone();
237    new_annotation.commit = ctx.new_commit.clone();
238    new_annotation.timestamp = Utc::now().to_rfc3339();
239
240    let is_message_only = ctx.new_diff.trim().is_empty();
241
242    new_annotation.provenance = Provenance {
243        operation: ProvenanceOperation::Amend,
244        derived_from: vec![ctx.old_annotation.commit.clone()],
245        original_annotations_preserved: true,
246        synthesis_notes: if is_message_only {
247            Some("Message-only amend; annotation unchanged.".to_string())
248        } else {
249            Some("Migrated from amend. Regions preserved from original annotation.".to_string())
250        },
251    };
252
253    // For message-only amends, update the summary to match new message
254    if is_message_only {
255        new_annotation.summary = ctx.new_message.clone();
256    }
257
258    new_annotation
259}
260
261/// Collect annotations from source commits using git notes.
262pub fn collect_source_annotations(
263    git_ops: &dyn GitOps,
264    source_shas: &[String],
265) -> Vec<(String, Option<Annotation>)> {
266    source_shas
267        .iter()
268        .map(|sha| {
269            let annotation = git_ops
270                .note_read(sha)
271                .ok()
272                .flatten()
273                .and_then(|json| serde_json::from_str::<Annotation>(&json).ok());
274            (sha.clone(), annotation)
275        })
276        .collect()
277}
278
279/// Collect commit messages from source commits.
280pub fn collect_source_messages(
281    git_ops: &dyn GitOps,
282    source_shas: &[String],
283) -> Vec<(String, String)> {
284    source_shas
285        .iter()
286        .filter_map(|sha| {
287            git_ops
288                .commit_info(sha)
289                .ok()
290                .map(|info| (sha.clone(), info.message))
291        })
292        .collect()
293}
294
295// ===========================================================================
296// V3 squash synthesis
297// ===========================================================================
298
299/// Context for v3 squash synthesis.
300#[derive(Debug, Clone)]
301pub struct SquashSynthesisContextV3 {
302    /// The squash commit's SHA.
303    pub squash_commit: String,
304    /// The squash commit's own commit message.
305    pub squash_message: String,
306    /// V3 annotations from source commits (normalized via parse_annotation).
307    pub source_annotations: Vec<v3::Annotation>,
308    /// Commit messages from source commits: (sha, message).
309    pub source_messages: Vec<(String, String)>,
310}
311
312/// Synthesize a v3 annotation from multiple source annotations (squash merge).
313///
314/// Merges wisdom entries (deduplicated by exact category+content match),
315/// sets provenance with all source SHAs.
316pub fn synthesize_squash_annotation_v3(ctx: &SquashSynthesisContextV3) -> v3::Annotation {
317    let mut all_wisdom: Vec<v3::WisdomEntry> = Vec::new();
318    let mut source_shas: Vec<String> = Vec::new();
319
320    for ann in &ctx.source_annotations {
321        source_shas.push(ann.commit.clone());
322
323        for entry in &ann.wisdom {
324            let already_exists = all_wisdom
325                .iter()
326                .any(|w| w.category == entry.category && w.content == entry.content);
327            if !already_exists {
328                all_wisdom.push(entry.clone());
329            }
330        }
331    }
332
333    // Add source SHAs from source_messages for commits that had no annotations
334    for (sha, _) in &ctx.source_messages {
335        if !source_shas.contains(sha) {
336            source_shas.push(sha.clone());
337        }
338    }
339
340    let annotations_count = ctx.source_annotations.len();
341    let total_sources = ctx.source_messages.len();
342
343    // Build provenance notes with synthesis metadata and source summaries
344    let mut notes_parts: Vec<String> = Vec::new();
345    if !ctx.source_annotations.is_empty() {
346        notes_parts.push(format!(
347            "Synthesized from {} commits ({} of {} had annotations).",
348            total_sources, annotations_count, total_sources,
349        ));
350    } else {
351        notes_parts.push(format!(
352            "Synthesized from {} commits (none had annotations).",
353            total_sources,
354        ));
355    }
356
357    // Include source summaries for traceability
358    for ann in &ctx.source_annotations {
359        let short_sha = if ann.commit.len() > 8 {
360            &ann.commit[..8]
361        } else {
362            &ann.commit
363        };
364        notes_parts.push(format!("{}: {}", short_sha, ann.summary));
365    }
366
367    v3::Annotation {
368        schema: "chronicle/v3".to_string(),
369        commit: ctx.squash_commit.clone(),
370        timestamp: Utc::now().to_rfc3339(),
371        summary: ctx.squash_message.clone(),
372        wisdom: all_wisdom,
373        provenance: v3::Provenance {
374            source: v3::ProvenanceSource::Squash,
375            author: None,
376            derived_from: source_shas,
377            notes: Some(notes_parts.join("\n")),
378        },
379    }
380}
381
382/// Collect annotations from source commits, normalizing all versions to v3
383/// via `schema::parse_annotation()`.
384pub fn collect_source_annotations_v3(
385    git_ops: &dyn GitOps,
386    source_shas: &[String],
387) -> Vec<(String, Option<v3::Annotation>)> {
388    source_shas
389        .iter()
390        .map(|sha| {
391            let annotation = git_ops
392                .note_read(sha)
393                .ok()
394                .flatten()
395                .and_then(|json| schema::parse_annotation(&json).ok());
396            (sha.clone(), annotation)
397        })
398        .collect()
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::schema::common::{AstAnchor, LineRange};
405    use crate::schema::v1::{
406        Constraint, ConstraintSource, CrossCuttingConcern, CrossCuttingRegionRef,
407        SemanticDependency,
408    };
409
410    fn make_test_annotation(commit: &str, file: &str, anchor: &str) -> Annotation {
411        Annotation {
412            schema: "chronicle/v1".to_string(),
413            commit: commit.to_string(),
414            timestamp: Utc::now().to_rfc3339(),
415            task: None,
416            summary: format!("Commit {commit}"),
417            context_level: ContextLevel::Inferred,
418            regions: vec![RegionAnnotation {
419                file: file.to_string(),
420                ast_anchor: AstAnchor {
421                    unit_type: "function".to_string(),
422                    name: anchor.to_string(),
423                    signature: None,
424                },
425                lines: LineRange { start: 1, end: 10 },
426                intent: format!("Modified {anchor}"),
427                reasoning: Some(format!("Reasoning for {anchor} in {commit}")),
428                constraints: vec![Constraint {
429                    text: format!("Constraint from {commit}"),
430                    source: ConstraintSource::Inferred,
431                }],
432                semantic_dependencies: vec![SemanticDependency {
433                    file: "other.rs".to_string(),
434                    anchor: "helper".to_string(),
435                    nature: "calls".to_string(),
436                }],
437                related_annotations: Vec::new(),
438                tags: Vec::new(),
439                risk_notes: None,
440                corrections: vec![],
441            }],
442            cross_cutting: vec![CrossCuttingConcern {
443                description: format!("Cross-cutting from {commit}"),
444                regions: vec![CrossCuttingRegionRef {
445                    file: file.to_string(),
446                    anchor: anchor.to_string(),
447                }],
448                tags: Vec::new(),
449            }],
450            provenance: Provenance {
451                operation: ProvenanceOperation::Initial,
452                derived_from: Vec::new(),
453                original_annotations_preserved: false,
454                synthesis_notes: None,
455            },
456        }
457    }
458
459    #[test]
460    fn test_pending_squash_roundtrip() {
461        let dir = tempfile::tempdir().unwrap();
462        let git_dir = dir.path();
463        std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
464
465        let pending = PendingSquash {
466            source_commits: vec!["abc123".to_string(), "def456".to_string()],
467            source_ref: Some("feature-branch".to_string()),
468            timestamp: Utc::now(),
469        };
470
471        write_pending_squash(git_dir, &pending).unwrap();
472        let read_back = read_pending_squash(git_dir).unwrap().unwrap();
473
474        assert_eq!(read_back.source_commits, pending.source_commits);
475        assert_eq!(read_back.source_ref, pending.source_ref);
476    }
477
478    #[test]
479    fn test_pending_squash_missing_file() {
480        let dir = tempfile::tempdir().unwrap();
481        let result = read_pending_squash(dir.path()).unwrap();
482        assert!(result.is_none());
483    }
484
485    #[test]
486    fn test_pending_squash_stale_file() {
487        let dir = tempfile::tempdir().unwrap();
488        let git_dir = dir.path();
489        std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
490
491        let pending = PendingSquash {
492            source_commits: vec!["abc123".to_string()],
493            source_ref: None,
494            timestamp: Utc::now() - chrono::Duration::seconds(120),
495        };
496
497        write_pending_squash(git_dir, &pending).unwrap();
498        let result = read_pending_squash(git_dir).unwrap();
499        assert!(result.is_none());
500        // File should have been deleted
501        assert!(!pending_squash_path(git_dir).exists());
502    }
503
504    #[test]
505    fn test_pending_squash_invalid_json() {
506        let dir = tempfile::tempdir().unwrap();
507        let git_dir = dir.path();
508        let chronicle_dir = git_dir.join("chronicle");
509        std::fs::create_dir_all(&chronicle_dir).unwrap();
510        std::fs::write(chronicle_dir.join("pending-squash.json"), "not json").unwrap();
511
512        let result = read_pending_squash(git_dir).unwrap();
513        assert!(result.is_none());
514        // File should have been deleted
515        assert!(!pending_squash_path(git_dir).exists());
516    }
517
518    #[test]
519    fn test_delete_pending_squash() {
520        let dir = tempfile::tempdir().unwrap();
521        let git_dir = dir.path();
522
523        let pending = PendingSquash {
524            source_commits: vec!["abc123".to_string()],
525            source_ref: None,
526            timestamp: Utc::now(),
527        };
528
529        write_pending_squash(git_dir, &pending).unwrap();
530        assert!(pending_squash_path(git_dir).exists());
531
532        delete_pending_squash(git_dir).unwrap();
533        assert!(!pending_squash_path(git_dir).exists());
534    }
535
536    #[test]
537    fn test_delete_pending_squash_missing_file() {
538        let dir = tempfile::tempdir().unwrap();
539        // Should not error when file doesn't exist
540        delete_pending_squash(dir.path()).unwrap();
541    }
542
543    #[test]
544    fn test_synthesize_squash_distinct_regions() {
545        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
546        let ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
547        let ann3 = make_test_annotation("ghi789", "src/baz.rs", "baz_fn");
548
549        let ctx = SquashSynthesisContext {
550            squash_commit: "squash001".to_string(),
551            diff: "some diff".to_string(),
552            source_annotations: vec![ann1, ann2, ann3],
553            source_messages: vec![
554                ("abc123".to_string(), "Commit abc".to_string()),
555                ("def456".to_string(), "Commit def".to_string()),
556                ("ghi789".to_string(), "Commit ghi".to_string()),
557            ],
558            squash_message: "Squash merge".to_string(),
559        };
560
561        let result = synthesize_squash_annotation(&ctx);
562
563        assert_eq!(result.commit, "squash001");
564        assert_eq!(result.regions.len(), 3);
565        assert_eq!(result.cross_cutting.len(), 3);
566        assert_eq!(result.provenance.operation, ProvenanceOperation::Squash);
567        assert_eq!(result.provenance.derived_from.len(), 3);
568        assert!(result.provenance.original_annotations_preserved);
569    }
570
571    #[test]
572    fn test_synthesize_squash_overlapping_regions() {
573        let ann1 = make_test_annotation("abc123", "src/foo.rs", "connect");
574        let mut ann2 = make_test_annotation("def456", "src/foo.rs", "connect");
575        // Give ann2 a different constraint
576        ann2.regions[0].constraints[0].text = "Constraint from def456".to_string();
577        ann2.regions[0].lines = LineRange { start: 5, end: 20 };
578
579        let ctx = SquashSynthesisContext {
580            squash_commit: "squash001".to_string(),
581            diff: "some diff".to_string(),
582            source_annotations: vec![ann1, ann2],
583            source_messages: vec![
584                ("abc123".to_string(), "First".to_string()),
585                ("def456".to_string(), "Second".to_string()),
586            ],
587            squash_message: "Squash merge".to_string(),
588        };
589
590        let result = synthesize_squash_annotation(&ctx);
591
592        // Should have merged into 1 region
593        assert_eq!(result.regions.len(), 1);
594        // Constraints from both should be preserved
595        assert_eq!(result.regions[0].constraints.len(), 2);
596        // Line range should encompass both
597        assert_eq!(result.regions[0].lines.start, 1);
598        assert_eq!(result.regions[0].lines.end, 20);
599        // Reasoning should be consolidated
600        assert!(result.regions[0]
601            .reasoning
602            .as_ref()
603            .unwrap()
604            .contains("abc123"));
605        assert!(result.regions[0]
606            .reasoning
607            .as_ref()
608            .unwrap()
609            .contains("def456"));
610    }
611
612    #[test]
613    fn test_synthesize_squash_partial_annotations() {
614        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
615
616        let ctx = SquashSynthesisContext {
617            squash_commit: "squash001".to_string(),
618            diff: "some diff".to_string(),
619            source_annotations: vec![ann1],
620            source_messages: vec![
621                ("abc123".to_string(), "First".to_string()),
622                ("def456".to_string(), "Second".to_string()),
623                ("ghi789".to_string(), "Third".to_string()),
624            ],
625            squash_message: "Squash merge".to_string(),
626        };
627
628        let result = synthesize_squash_annotation(&ctx);
629
630        assert!(!result.provenance.original_annotations_preserved);
631        assert!(result
632            .provenance
633            .synthesis_notes
634            .as_ref()
635            .unwrap()
636            .contains("1 of 3"));
637    }
638
639    #[test]
640    fn test_synthesize_squash_no_annotations() {
641        let ctx = SquashSynthesisContext {
642            squash_commit: "squash001".to_string(),
643            diff: "some diff".to_string(),
644            source_annotations: vec![],
645            source_messages: vec![
646                ("abc123".to_string(), "First".to_string()),
647                ("def456".to_string(), "Second".to_string()),
648            ],
649            squash_message: "Squash merge".to_string(),
650        };
651
652        let result = synthesize_squash_annotation(&ctx);
653
654        assert_eq!(result.context_level, ContextLevel::Inferred);
655        assert!(result.regions.is_empty());
656        assert!(!result.provenance.original_annotations_preserved);
657    }
658
659    #[test]
660    fn test_synthesize_preserves_cross_cutting() {
661        let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
662        let mut ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
663        // Add a second cross-cutting concern to ann2
664        ann2.cross_cutting.push(CrossCuttingConcern {
665            description: "Another concern".to_string(),
666            regions: vec![CrossCuttingRegionRef {
667                file: "src/bar.rs".to_string(),
668                anchor: "bar_fn".to_string(),
669            }],
670            tags: Vec::new(),
671        });
672
673        let ctx = SquashSynthesisContext {
674            squash_commit: "squash001".to_string(),
675            diff: "some diff".to_string(),
676            source_annotations: vec![ann1, ann2],
677            source_messages: vec![
678                ("abc123".to_string(), "First".to_string()),
679                ("def456".to_string(), "Second".to_string()),
680            ],
681            squash_message: "Squash merge".to_string(),
682        };
683
684        let result = synthesize_squash_annotation(&ctx);
685        // 1 from ann1, 2 from ann2 = 3 unique cross-cutting concerns
686        assert_eq!(result.cross_cutting.len(), 3);
687    }
688
689    #[test]
690    fn test_migrate_amend_message_only() {
691        let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
692
693        let ctx = AmendMigrationContext {
694            new_commit: "new_sha".to_string(),
695            new_diff: "".to_string(), // empty = message-only
696            old_annotation: old_ann,
697            new_message: "Updated commit message".to_string(),
698        };
699
700        let result = migrate_amend_annotation(&ctx);
701
702        assert_eq!(result.commit, "new_sha");
703        assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
704        assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
705        assert!(result.provenance.original_annotations_preserved);
706        assert!(result
707            .provenance
708            .synthesis_notes
709            .as_ref()
710            .unwrap()
711            .contains("Message-only"));
712        assert_eq!(result.summary, "Updated commit message");
713        // Regions should be preserved
714        assert_eq!(result.regions.len(), 1);
715    }
716
717    #[test]
718    fn test_migrate_amend_with_code_changes() {
719        let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
720
721        let ctx = AmendMigrationContext {
722            new_commit: "new_sha".to_string(),
723            new_diff: "+some new code\n-some old code\n".to_string(),
724            old_annotation: old_ann,
725            new_message: "Updated commit".to_string(),
726        };
727
728        let result = migrate_amend_annotation(&ctx);
729
730        assert_eq!(result.commit, "new_sha");
731        assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
732        assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
733        assert!(result
734            .provenance
735            .synthesis_notes
736            .as_ref()
737            .unwrap()
738            .contains("Migrated from amend"));
739        // Regions preserved from original (MVP doesn't re-analyze)
740        assert_eq!(result.regions.len(), 1);
741    }
742
743    // -----------------------------------------------------------------------
744    // V3 squash synthesis tests
745    // -----------------------------------------------------------------------
746
747    fn make_v3_annotation(commit: &str, wisdom: Vec<v3::WisdomEntry>) -> v3::Annotation {
748        v3::Annotation {
749            schema: "chronicle/v3".to_string(),
750            commit: commit.to_string(),
751            timestamp: Utc::now().to_rfc3339(),
752            summary: format!("Commit {commit}"),
753            wisdom,
754            provenance: v3::Provenance {
755                source: v3::ProvenanceSource::Live,
756                author: None,
757                derived_from: Vec::new(),
758                notes: None,
759            },
760        }
761    }
762
763    fn wisdom(category: v3::WisdomCategory, content: &str) -> v3::WisdomEntry {
764        v3::WisdomEntry {
765            category,
766            content: content.to_string(),
767            file: None,
768            lines: None,
769        }
770    }
771
772    fn wisdom_with_file(
773        category: v3::WisdomCategory,
774        content: &str,
775        file: &str,
776    ) -> v3::WisdomEntry {
777        v3::WisdomEntry {
778            category,
779            content: content.to_string(),
780            file: Some(file.to_string()),
781            lines: None,
782        }
783    }
784
785    #[test]
786    fn test_synthesize_squash_v3_merges_wisdom() {
787        let ann1 = make_v3_annotation(
788            "abc123",
789            vec![
790                wisdom(v3::WisdomCategory::DeadEnd, "Tried approach X"),
791                wisdom_with_file(
792                    v3::WisdomCategory::Gotcha,
793                    "Must validate input",
794                    "src/input.rs",
795                ),
796            ],
797        );
798        let ann2 = make_v3_annotation(
799            "def456",
800            vec![
801                wisdom(v3::WisdomCategory::Insight, "HashMap is O(1)"),
802                wisdom_with_file(
803                    v3::WisdomCategory::Gotcha,
804                    "Connection can timeout",
805                    "src/net.rs",
806                ),
807            ],
808        );
809
810        let ctx = SquashSynthesisContextV3 {
811            squash_commit: "squash001".to_string(),
812            squash_message: "Squash merge PR #42".to_string(),
813            source_annotations: vec![ann1, ann2],
814            source_messages: vec![
815                ("abc123".to_string(), "First commit".to_string()),
816                ("def456".to_string(), "Second commit".to_string()),
817            ],
818        };
819
820        let result = synthesize_squash_annotation_v3(&ctx);
821
822        assert_eq!(result.schema, "chronicle/v3");
823        assert_eq!(result.commit, "squash001");
824        assert_eq!(result.summary, "Squash merge PR #42");
825        assert_eq!(result.wisdom.len(), 4);
826        assert!(result
827            .wisdom
828            .iter()
829            .any(|w| w.category == v3::WisdomCategory::DeadEnd && w.content == "Tried approach X"));
830        assert!(result
831            .wisdom
832            .iter()
833            .any(|w| w.category == v3::WisdomCategory::Gotcha
834                && w.content == "Must validate input"
835                && w.file.as_deref() == Some("src/input.rs")));
836    }
837
838    #[test]
839    fn test_synthesize_squash_v3_deduplicates_wisdom() {
840        // Same (category, content) across two annotations should be deduplicated
841        let shared_wisdom = wisdom(v3::WisdomCategory::Gotcha, "Must validate input");
842        let ann1 = make_v3_annotation("abc123", vec![shared_wisdom.clone()]);
843        let ann2 = make_v3_annotation(
844            "def456",
845            vec![
846                shared_wisdom,
847                wisdom(v3::WisdomCategory::Insight, "Unique to ann2"),
848            ],
849        );
850
851        let ctx = SquashSynthesisContextV3 {
852            squash_commit: "squash001".to_string(),
853            squash_message: "Squash".to_string(),
854            source_annotations: vec![ann1, ann2],
855            source_messages: vec![
856                ("abc123".to_string(), "First".to_string()),
857                ("def456".to_string(), "Second".to_string()),
858            ],
859        };
860
861        let result = synthesize_squash_annotation_v3(&ctx);
862
863        // Should have 2 entries (1 deduplicated + 1 unique), not 3
864        assert_eq!(result.wisdom.len(), 2);
865        let gotcha_count = result
866            .wisdom
867            .iter()
868            .filter(|w| {
869                w.category == v3::WisdomCategory::Gotcha && w.content == "Must validate input"
870            })
871            .count();
872        assert_eq!(gotcha_count, 1);
873    }
874
875    #[test]
876    fn test_synthesize_squash_v3_no_annotations() {
877        let ctx = SquashSynthesisContextV3 {
878            squash_commit: "squash001".to_string(),
879            squash_message: "Squash merge".to_string(),
880            source_annotations: vec![],
881            source_messages: vec![
882                ("abc123".to_string(), "First".to_string()),
883                ("def456".to_string(), "Second".to_string()),
884            ],
885        };
886
887        let result = synthesize_squash_annotation_v3(&ctx);
888
889        assert_eq!(result.schema, "chronicle/v3");
890        assert!(result.wisdom.is_empty());
891        assert_eq!(result.provenance.source, v3::ProvenanceSource::Squash);
892        assert_eq!(result.provenance.derived_from.len(), 2);
893        assert!(result
894            .provenance
895            .notes
896            .as_ref()
897            .unwrap()
898            .contains("none had annotations"));
899    }
900
901    #[test]
902    fn test_synthesize_squash_v3_partial_annotations() {
903        let ann1 = make_v3_annotation(
904            "abc123",
905            vec![wisdom(v3::WisdomCategory::Insight, "Only this one")],
906        );
907
908        let ctx = SquashSynthesisContextV3 {
909            squash_commit: "squash001".to_string(),
910            squash_message: "Squash merge".to_string(),
911            source_annotations: vec![ann1],
912            source_messages: vec![
913                ("abc123".to_string(), "First".to_string()),
914                ("def456".to_string(), "Second".to_string()),
915                ("ghi789".to_string(), "Third".to_string()),
916            ],
917        };
918
919        let result = synthesize_squash_annotation_v3(&ctx);
920
921        assert_eq!(result.wisdom.len(), 1);
922        assert!(result.provenance.notes.as_ref().unwrap().contains("1 of 3"));
923        // All 3 source SHAs should be in derived_from
924        assert_eq!(result.provenance.derived_from.len(), 3);
925    }
926
927    #[test]
928    fn test_synthesize_squash_v3_provenance() {
929        let ann1 = make_v3_annotation(
930            "abc12345678",
931            vec![wisdom(v3::WisdomCategory::DeadEnd, "Dead end")],
932        );
933        let ann2 = make_v3_annotation(
934            "def45678901",
935            vec![wisdom(v3::WisdomCategory::Insight, "Insight")],
936        );
937
938        let ctx = SquashSynthesisContextV3 {
939            squash_commit: "squash001".to_string(),
940            squash_message: "Squash merge".to_string(),
941            source_annotations: vec![ann1, ann2],
942            source_messages: vec![
943                ("abc12345678".to_string(), "First".to_string()),
944                ("def45678901".to_string(), "Second".to_string()),
945            ],
946        };
947
948        let result = synthesize_squash_annotation_v3(&ctx);
949
950        assert_eq!(result.provenance.source, v3::ProvenanceSource::Squash);
951        assert_eq!(
952            result.provenance.derived_from,
953            vec!["abc12345678".to_string(), "def45678901".to_string()]
954        );
955        let notes = result.provenance.notes.unwrap();
956        assert!(notes.contains("Synthesized from 2 commits"));
957        assert!(notes.contains("2 of 2 had annotations"));
958        // Source summaries should be included
959        assert!(notes.contains("abc12345: Commit abc12345678"));
960        assert!(notes.contains("def45678: Commit def45678901"));
961    }
962
963    #[test]
964    fn test_collect_source_annotations_v3_handles_all_versions() {
965        use crate::error::GitError;
966        use crate::git::diff::FileDiff;
967        use crate::git::CommitInfo;
968        use std::collections::HashMap;
969
970        struct MockGitOpsForNotes {
971            notes: HashMap<String, String>,
972        }
973
974        impl GitOps for MockGitOpsForNotes {
975            fn diff(&self, _: &str) -> std::result::Result<Vec<FileDiff>, GitError> {
976                Ok(vec![])
977            }
978            fn note_read(&self, commit: &str) -> std::result::Result<Option<String>, GitError> {
979                Ok(self.notes.get(commit).cloned())
980            }
981            fn note_write(&self, _: &str, _: &str) -> std::result::Result<(), GitError> {
982                Ok(())
983            }
984            fn note_exists(&self, _: &str) -> std::result::Result<bool, GitError> {
985                Ok(false)
986            }
987            fn file_at_commit(
988                &self,
989                _: &std::path::Path,
990                _: &str,
991            ) -> std::result::Result<String, GitError> {
992                Ok(String::new())
993            }
994            fn commit_info(&self, _: &str) -> std::result::Result<CommitInfo, GitError> {
995                Ok(CommitInfo {
996                    sha: String::new(),
997                    message: String::new(),
998                    author_name: String::new(),
999                    author_email: String::new(),
1000                    timestamp: String::new(),
1001                    parent_shas: Vec::new(),
1002                })
1003            }
1004            fn resolve_ref(&self, _: &str) -> std::result::Result<String, GitError> {
1005                Ok(String::new())
1006            }
1007            fn config_get(&self, _: &str) -> std::result::Result<Option<String>, GitError> {
1008                Ok(None)
1009            }
1010            fn config_set(&self, _: &str, _: &str) -> std::result::Result<(), GitError> {
1011                Ok(())
1012            }
1013            fn log_for_file(&self, _: &str) -> std::result::Result<Vec<String>, GitError> {
1014                Ok(vec![])
1015            }
1016            fn list_annotated_commits(&self, _: u32) -> std::result::Result<Vec<String>, GitError> {
1017                Ok(vec![])
1018            }
1019        }
1020
1021        let v1_json = r#"{
1022            "schema": "chronicle/v1",
1023            "commit": "v1sha",
1024            "timestamp": "2025-01-01T00:00:00Z",
1025            "summary": "V1 annotation",
1026            "context_level": "enhanced",
1027            "regions": [],
1028            "provenance": {
1029                "operation": "initial",
1030                "derived_from": [],
1031                "original_annotations_preserved": false
1032            }
1033        }"#;
1034
1035        let v3_json = r#"{
1036            "schema": "chronicle/v3",
1037            "commit": "v3sha",
1038            "timestamp": "2025-06-01T00:00:00Z",
1039            "summary": "V3 annotation",
1040            "wisdom": [{"category": "gotcha", "content": "Watch out"}],
1041            "provenance": {"source": "live"}
1042        }"#;
1043
1044        let mut notes = HashMap::new();
1045        notes.insert("v1sha".to_string(), v1_json.to_string());
1046        notes.insert("v3sha".to_string(), v3_json.to_string());
1047        // no_ann_sha has no note
1048
1049        let mock = MockGitOpsForNotes { notes };
1050        let shas = vec![
1051            "v1sha".to_string(),
1052            "v3sha".to_string(),
1053            "no_ann_sha".to_string(),
1054        ];
1055
1056        let results = collect_source_annotations_v3(&mock, &shas);
1057
1058        assert_eq!(results.len(), 3);
1059
1060        // v1 annotation should be parsed and migrated to v3
1061        let (sha, ann) = &results[0];
1062        assert_eq!(sha, "v1sha");
1063        let ann = ann.as_ref().unwrap();
1064        assert_eq!(ann.schema, "chronicle/v3");
1065        assert_eq!(ann.summary, "V1 annotation");
1066
1067        // v3 annotation should parse directly
1068        let (sha, ann) = &results[1];
1069        assert_eq!(sha, "v3sha");
1070        let ann = ann.as_ref().unwrap();
1071        assert_eq!(ann.schema, "chronicle/v3");
1072        assert_eq!(ann.wisdom.len(), 1);
1073
1074        // Missing annotation should be None
1075        let (sha, ann) = &results[2];
1076        assert_eq!(sha, "no_ann_sha");
1077        assert!(ann.is_none());
1078    }
1079}