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::{
10 Annotation, ContextLevel, CrossCuttingConcern, Provenance, ProvenanceOperation,
11 RegionAnnotation,
12};
13use snafu::ResultExt;
14
15const PENDING_SQUASH_EXPIRY_SECS: i64 = 60;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PendingSquash {
22 pub source_commits: Vec<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub source_ref: Option<String>,
25 pub timestamp: DateTime<Utc>,
26}
27
28#[derive(Debug, Clone)]
30pub struct SquashSynthesisContext {
31 pub squash_commit: String,
33 pub diff: String,
35 pub source_annotations: Vec<Annotation>,
37 pub source_messages: Vec<(String, String)>,
39 pub squash_message: String,
41}
42
43#[derive(Debug, Clone)]
45pub struct AmendMigrationContext {
46 pub new_commit: String,
48 pub new_diff: String,
50 pub old_annotation: Annotation,
52 pub new_message: String,
54}
55
56fn pending_squash_path(git_dir: &Path) -> std::path::PathBuf {
57 git_dir.join("chronicle").join("pending-squash.json")
58}
59
60pub fn write_pending_squash(git_dir: &Path, pending: &PendingSquash) -> Result<()> {
62 let path = pending_squash_path(git_dir);
63 if let Some(parent) = path.parent() {
64 std::fs::create_dir_all(parent).context(IoSnafu)?;
65 }
66 let json = serde_json::to_string_pretty(pending).context(JsonSnafu)?;
67 std::fs::write(&path, json).context(IoSnafu)?;
68 Ok(())
69}
70
71pub fn read_pending_squash(git_dir: &Path) -> Result<Option<PendingSquash>> {
74 let path = pending_squash_path(git_dir);
75 if !path.exists() {
76 return Ok(None);
77 }
78
79 let content = std::fs::read_to_string(&path).context(IoSnafu)?;
80 let pending: PendingSquash = match serde_json::from_str(&content) {
81 Ok(p) => p,
82 Err(e) => {
83 tracing::warn!("Invalid pending-squash.json, deleting: {e}");
84 let _ = std::fs::remove_file(&path);
85 return Ok(None);
86 }
87 };
88
89 let age = Utc::now() - pending.timestamp;
90 if age.num_seconds() > PENDING_SQUASH_EXPIRY_SECS {
91 tracing::warn!(
92 "Stale pending-squash.json ({}s old), deleting",
93 age.num_seconds()
94 );
95 std::fs::remove_file(&path).context(IoSnafu)?;
96 return Ok(None);
97 }
98
99 Ok(Some(pending))
100}
101
102pub fn delete_pending_squash(git_dir: &Path) -> Result<()> {
104 let path = pending_squash_path(git_dir);
105 if path.exists() {
106 std::fs::remove_file(&path).context(IoSnafu)?;
107 }
108 Ok(())
109}
110
111pub fn synthesize_squash_annotation(ctx: &SquashSynthesisContext) -> Annotation {
117 let mut all_regions: Vec<RegionAnnotation> = Vec::new();
118 let mut all_cross_cutting: Vec<CrossCuttingConcern> = Vec::new();
119 let mut source_shas: Vec<String> = Vec::new();
120 let has_annotations = !ctx.source_annotations.is_empty();
121
122 for ann in &ctx.source_annotations {
123 source_shas.push(ann.commit.clone());
124
125 for region in &ann.regions {
127 let already_exists = all_regions
128 .iter()
129 .any(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name);
130 if already_exists {
131 if let Some(existing) = all_regions
133 .iter_mut()
134 .find(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name)
135 {
136 for constraint in ®ion.constraints {
138 if !existing
139 .constraints
140 .iter()
141 .any(|c| c.text == constraint.text)
142 {
143 existing.constraints.push(constraint.clone());
144 }
145 }
146 for dep in ®ion.semantic_dependencies {
148 if !existing
149 .semantic_dependencies
150 .iter()
151 .any(|d| d.file == dep.file && d.anchor == dep.anchor)
152 {
153 existing.semantic_dependencies.push(dep.clone());
154 }
155 }
156 if let Some(new_reasoning) = ®ion.reasoning {
158 if let Some(ref mut existing_reasoning) = existing.reasoning {
159 existing_reasoning.push_str("\n\n");
160 existing_reasoning.push_str(new_reasoning);
161 } else {
162 existing.reasoning = Some(new_reasoning.clone());
163 }
164 }
165 existing.lines.start = existing.lines.start.min(region.lines.start);
167 existing.lines.end = existing.lines.end.max(region.lines.end);
168 }
169 } else {
170 all_regions.push(region.clone());
171 }
172 }
173
174 for cc in &ann.cross_cutting {
176 if !all_cross_cutting
177 .iter()
178 .any(|c| c.description == cc.description)
179 {
180 all_cross_cutting.push(cc.clone());
181 }
182 }
183 }
184
185 for (sha, _) in &ctx.source_messages {
187 if !source_shas.contains(sha) {
188 source_shas.push(sha.clone());
189 }
190 }
191
192 let annotations_count = ctx.source_annotations.len();
193 let total_sources = ctx.source_messages.len();
194 let all_had_annotations = annotations_count == total_sources && total_sources > 0;
195
196 let synthesis_notes = if has_annotations {
197 Some(format!(
198 "Synthesized from {} commits ({} of {} had annotations).",
199 total_sources, annotations_count, total_sources,
200 ))
201 } else {
202 Some(format!(
203 "Synthesized from {} commits (none had annotations).",
204 total_sources,
205 ))
206 };
207
208 Annotation {
209 schema: "chronicle/v1".to_string(),
210 commit: ctx.squash_commit.clone(),
211 timestamp: Utc::now().to_rfc3339(),
212 task: None,
213 summary: ctx.squash_message.clone(),
214 context_level: if has_annotations {
215 ContextLevel::Enhanced
216 } else {
217 ContextLevel::Inferred
218 },
219 regions: all_regions,
220 cross_cutting: all_cross_cutting,
221 provenance: Provenance {
222 operation: ProvenanceOperation::Squash,
223 derived_from: source_shas,
224 original_annotations_preserved: all_had_annotations,
225 synthesis_notes,
226 },
227 }
228}
229
230pub fn migrate_amend_annotation(ctx: &AmendMigrationContext) -> Annotation {
235 let mut new_annotation = ctx.old_annotation.clone();
236 new_annotation.commit = ctx.new_commit.clone();
237 new_annotation.timestamp = Utc::now().to_rfc3339();
238
239 let is_message_only = ctx.new_diff.trim().is_empty();
240
241 new_annotation.provenance = Provenance {
242 operation: ProvenanceOperation::Amend,
243 derived_from: vec![ctx.old_annotation.commit.clone()],
244 original_annotations_preserved: true,
245 synthesis_notes: if is_message_only {
246 Some("Message-only amend; annotation unchanged.".to_string())
247 } else {
248 Some("Migrated from amend. Regions preserved from original annotation.".to_string())
249 },
250 };
251
252 if is_message_only {
254 new_annotation.summary = ctx.new_message.clone();
255 }
256
257 new_annotation
258}
259
260pub fn collect_source_annotations(
262 git_ops: &dyn GitOps,
263 source_shas: &[String],
264) -> Vec<(String, Option<Annotation>)> {
265 source_shas
266 .iter()
267 .map(|sha| {
268 let annotation = git_ops
269 .note_read(sha)
270 .ok()
271 .flatten()
272 .and_then(|json| serde_json::from_str::<Annotation>(&json).ok());
273 (sha.clone(), annotation)
274 })
275 .collect()
276}
277
278pub fn collect_source_messages(
280 git_ops: &dyn GitOps,
281 source_shas: &[String],
282) -> Vec<(String, String)> {
283 source_shas
284 .iter()
285 .filter_map(|sha| {
286 git_ops
287 .commit_info(sha)
288 .ok()
289 .map(|info| (sha.clone(), info.message))
290 })
291 .collect()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::schema::{
298 AstAnchor, Constraint, ConstraintSource, CrossCuttingConcern, CrossCuttingRegionRef,
299 LineRange, SemanticDependency,
300 };
301
302 fn make_test_annotation(commit: &str, file: &str, anchor: &str) -> Annotation {
303 Annotation {
304 schema: "chronicle/v1".to_string(),
305 commit: commit.to_string(),
306 timestamp: Utc::now().to_rfc3339(),
307 task: None,
308 summary: format!("Commit {commit}"),
309 context_level: ContextLevel::Inferred,
310 regions: vec![RegionAnnotation {
311 file: file.to_string(),
312 ast_anchor: AstAnchor {
313 unit_type: "function".to_string(),
314 name: anchor.to_string(),
315 signature: None,
316 },
317 lines: LineRange { start: 1, end: 10 },
318 intent: format!("Modified {anchor}"),
319 reasoning: Some(format!("Reasoning for {anchor} in {commit}")),
320 constraints: vec![Constraint {
321 text: format!("Constraint from {commit}"),
322 source: ConstraintSource::Inferred,
323 }],
324 semantic_dependencies: vec![SemanticDependency {
325 file: "other.rs".to_string(),
326 anchor: "helper".to_string(),
327 nature: "calls".to_string(),
328 }],
329 related_annotations: Vec::new(),
330 tags: Vec::new(),
331 risk_notes: None,
332 corrections: vec![],
333 }],
334 cross_cutting: vec![CrossCuttingConcern {
335 description: format!("Cross-cutting from {commit}"),
336 regions: vec![CrossCuttingRegionRef {
337 file: file.to_string(),
338 anchor: anchor.to_string(),
339 }],
340 tags: Vec::new(),
341 }],
342 provenance: Provenance {
343 operation: ProvenanceOperation::Initial,
344 derived_from: Vec::new(),
345 original_annotations_preserved: false,
346 synthesis_notes: None,
347 },
348 }
349 }
350
351 #[test]
352 fn test_pending_squash_roundtrip() {
353 let dir = tempfile::tempdir().unwrap();
354 let git_dir = dir.path();
355 std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
356
357 let pending = PendingSquash {
358 source_commits: vec!["abc123".to_string(), "def456".to_string()],
359 source_ref: Some("feature-branch".to_string()),
360 timestamp: Utc::now(),
361 };
362
363 write_pending_squash(git_dir, &pending).unwrap();
364 let read_back = read_pending_squash(git_dir).unwrap().unwrap();
365
366 assert_eq!(read_back.source_commits, pending.source_commits);
367 assert_eq!(read_back.source_ref, pending.source_ref);
368 }
369
370 #[test]
371 fn test_pending_squash_missing_file() {
372 let dir = tempfile::tempdir().unwrap();
373 let result = read_pending_squash(dir.path()).unwrap();
374 assert!(result.is_none());
375 }
376
377 #[test]
378 fn test_pending_squash_stale_file() {
379 let dir = tempfile::tempdir().unwrap();
380 let git_dir = dir.path();
381 std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
382
383 let pending = PendingSquash {
384 source_commits: vec!["abc123".to_string()],
385 source_ref: None,
386 timestamp: Utc::now() - chrono::Duration::seconds(120),
387 };
388
389 write_pending_squash(git_dir, &pending).unwrap();
390 let result = read_pending_squash(git_dir).unwrap();
391 assert!(result.is_none());
392 assert!(!pending_squash_path(git_dir).exists());
394 }
395
396 #[test]
397 fn test_pending_squash_invalid_json() {
398 let dir = tempfile::tempdir().unwrap();
399 let git_dir = dir.path();
400 let chronicle_dir = git_dir.join("chronicle");
401 std::fs::create_dir_all(&chronicle_dir).unwrap();
402 std::fs::write(chronicle_dir.join("pending-squash.json"), "not json").unwrap();
403
404 let result = read_pending_squash(git_dir).unwrap();
405 assert!(result.is_none());
406 assert!(!pending_squash_path(git_dir).exists());
408 }
409
410 #[test]
411 fn test_delete_pending_squash() {
412 let dir = tempfile::tempdir().unwrap();
413 let git_dir = dir.path();
414
415 let pending = PendingSquash {
416 source_commits: vec!["abc123".to_string()],
417 source_ref: None,
418 timestamp: Utc::now(),
419 };
420
421 write_pending_squash(git_dir, &pending).unwrap();
422 assert!(pending_squash_path(git_dir).exists());
423
424 delete_pending_squash(git_dir).unwrap();
425 assert!(!pending_squash_path(git_dir).exists());
426 }
427
428 #[test]
429 fn test_delete_pending_squash_missing_file() {
430 let dir = tempfile::tempdir().unwrap();
431 delete_pending_squash(dir.path()).unwrap();
433 }
434
435 #[test]
436 fn test_synthesize_squash_distinct_regions() {
437 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
438 let ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
439 let ann3 = make_test_annotation("ghi789", "src/baz.rs", "baz_fn");
440
441 let ctx = SquashSynthesisContext {
442 squash_commit: "squash001".to_string(),
443 diff: "some diff".to_string(),
444 source_annotations: vec![ann1, ann2, ann3],
445 source_messages: vec![
446 ("abc123".to_string(), "Commit abc".to_string()),
447 ("def456".to_string(), "Commit def".to_string()),
448 ("ghi789".to_string(), "Commit ghi".to_string()),
449 ],
450 squash_message: "Squash merge".to_string(),
451 };
452
453 let result = synthesize_squash_annotation(&ctx);
454
455 assert_eq!(result.commit, "squash001");
456 assert_eq!(result.regions.len(), 3);
457 assert_eq!(result.cross_cutting.len(), 3);
458 assert_eq!(result.provenance.operation, ProvenanceOperation::Squash);
459 assert_eq!(result.provenance.derived_from.len(), 3);
460 assert!(result.provenance.original_annotations_preserved);
461 }
462
463 #[test]
464 fn test_synthesize_squash_overlapping_regions() {
465 let ann1 = make_test_annotation("abc123", "src/foo.rs", "connect");
466 let mut ann2 = make_test_annotation("def456", "src/foo.rs", "connect");
467 ann2.regions[0].constraints[0].text = "Constraint from def456".to_string();
469 ann2.regions[0].lines = LineRange { start: 5, end: 20 };
470
471 let ctx = SquashSynthesisContext {
472 squash_commit: "squash001".to_string(),
473 diff: "some diff".to_string(),
474 source_annotations: vec![ann1, ann2],
475 source_messages: vec![
476 ("abc123".to_string(), "First".to_string()),
477 ("def456".to_string(), "Second".to_string()),
478 ],
479 squash_message: "Squash merge".to_string(),
480 };
481
482 let result = synthesize_squash_annotation(&ctx);
483
484 assert_eq!(result.regions.len(), 1);
486 assert_eq!(result.regions[0].constraints.len(), 2);
488 assert_eq!(result.regions[0].lines.start, 1);
490 assert_eq!(result.regions[0].lines.end, 20);
491 assert!(result.regions[0]
493 .reasoning
494 .as_ref()
495 .unwrap()
496 .contains("abc123"));
497 assert!(result.regions[0]
498 .reasoning
499 .as_ref()
500 .unwrap()
501 .contains("def456"));
502 }
503
504 #[test]
505 fn test_synthesize_squash_partial_annotations() {
506 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
507
508 let ctx = SquashSynthesisContext {
509 squash_commit: "squash001".to_string(),
510 diff: "some diff".to_string(),
511 source_annotations: vec![ann1],
512 source_messages: vec![
513 ("abc123".to_string(), "First".to_string()),
514 ("def456".to_string(), "Second".to_string()),
515 ("ghi789".to_string(), "Third".to_string()),
516 ],
517 squash_message: "Squash merge".to_string(),
518 };
519
520 let result = synthesize_squash_annotation(&ctx);
521
522 assert!(!result.provenance.original_annotations_preserved);
523 assert!(result
524 .provenance
525 .synthesis_notes
526 .as_ref()
527 .unwrap()
528 .contains("1 of 3"));
529 }
530
531 #[test]
532 fn test_synthesize_squash_no_annotations() {
533 let ctx = SquashSynthesisContext {
534 squash_commit: "squash001".to_string(),
535 diff: "some diff".to_string(),
536 source_annotations: vec![],
537 source_messages: vec![
538 ("abc123".to_string(), "First".to_string()),
539 ("def456".to_string(), "Second".to_string()),
540 ],
541 squash_message: "Squash merge".to_string(),
542 };
543
544 let result = synthesize_squash_annotation(&ctx);
545
546 assert_eq!(result.context_level, ContextLevel::Inferred);
547 assert!(result.regions.is_empty());
548 assert!(!result.provenance.original_annotations_preserved);
549 }
550
551 #[test]
552 fn test_synthesize_preserves_cross_cutting() {
553 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
554 let mut ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
555 ann2.cross_cutting.push(CrossCuttingConcern {
557 description: "Another concern".to_string(),
558 regions: vec![CrossCuttingRegionRef {
559 file: "src/bar.rs".to_string(),
560 anchor: "bar_fn".to_string(),
561 }],
562 tags: Vec::new(),
563 });
564
565 let ctx = SquashSynthesisContext {
566 squash_commit: "squash001".to_string(),
567 diff: "some diff".to_string(),
568 source_annotations: vec![ann1, ann2],
569 source_messages: vec![
570 ("abc123".to_string(), "First".to_string()),
571 ("def456".to_string(), "Second".to_string()),
572 ],
573 squash_message: "Squash merge".to_string(),
574 };
575
576 let result = synthesize_squash_annotation(&ctx);
577 assert_eq!(result.cross_cutting.len(), 3);
579 }
580
581 #[test]
582 fn test_migrate_amend_message_only() {
583 let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
584
585 let ctx = AmendMigrationContext {
586 new_commit: "new_sha".to_string(),
587 new_diff: "".to_string(), old_annotation: old_ann,
589 new_message: "Updated commit message".to_string(),
590 };
591
592 let result = migrate_amend_annotation(&ctx);
593
594 assert_eq!(result.commit, "new_sha");
595 assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
596 assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
597 assert!(result.provenance.original_annotations_preserved);
598 assert!(result
599 .provenance
600 .synthesis_notes
601 .as_ref()
602 .unwrap()
603 .contains("Message-only"));
604 assert_eq!(result.summary, "Updated commit message");
605 assert_eq!(result.regions.len(), 1);
607 }
608
609 #[test]
610 fn test_migrate_amend_with_code_changes() {
611 let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
612
613 let ctx = AmendMigrationContext {
614 new_commit: "new_sha".to_string(),
615 new_diff: "+some new code\n-some old code\n".to_string(),
616 old_annotation: old_ann,
617 new_message: "Updated commit".to_string(),
618 };
619
620 let result = migrate_amend_annotation(&ctx);
621
622 assert_eq!(result.commit, "new_sha");
623 assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
624 assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
625 assert!(result
626 .provenance
627 .synthesis_notes
628 .as_ref()
629 .unwrap()
630 .contains("Migrated from amend"));
631 assert_eq!(result.regions.len(), 1);
633 }
634}