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};
12type Annotation = v1::Annotation;
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::common::{AstAnchor, LineRange};
298 use crate::schema::v1::{
299 Constraint, ConstraintSource, CrossCuttingConcern, CrossCuttingRegionRef,
300 SemanticDependency,
301 };
302
303 fn make_test_annotation(commit: &str, file: &str, anchor: &str) -> Annotation {
304 Annotation {
305 schema: "chronicle/v1".to_string(),
306 commit: commit.to_string(),
307 timestamp: Utc::now().to_rfc3339(),
308 task: None,
309 summary: format!("Commit {commit}"),
310 context_level: ContextLevel::Inferred,
311 regions: vec![RegionAnnotation {
312 file: file.to_string(),
313 ast_anchor: AstAnchor {
314 unit_type: "function".to_string(),
315 name: anchor.to_string(),
316 signature: None,
317 },
318 lines: LineRange { start: 1, end: 10 },
319 intent: format!("Modified {anchor}"),
320 reasoning: Some(format!("Reasoning for {anchor} in {commit}")),
321 constraints: vec![Constraint {
322 text: format!("Constraint from {commit}"),
323 source: ConstraintSource::Inferred,
324 }],
325 semantic_dependencies: vec![SemanticDependency {
326 file: "other.rs".to_string(),
327 anchor: "helper".to_string(),
328 nature: "calls".to_string(),
329 }],
330 related_annotations: Vec::new(),
331 tags: Vec::new(),
332 risk_notes: None,
333 corrections: vec![],
334 }],
335 cross_cutting: vec![CrossCuttingConcern {
336 description: format!("Cross-cutting from {commit}"),
337 regions: vec![CrossCuttingRegionRef {
338 file: file.to_string(),
339 anchor: anchor.to_string(),
340 }],
341 tags: Vec::new(),
342 }],
343 provenance: Provenance {
344 operation: ProvenanceOperation::Initial,
345 derived_from: Vec::new(),
346 original_annotations_preserved: false,
347 synthesis_notes: None,
348 },
349 }
350 }
351
352 #[test]
353 fn test_pending_squash_roundtrip() {
354 let dir = tempfile::tempdir().unwrap();
355 let git_dir = dir.path();
356 std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
357
358 let pending = PendingSquash {
359 source_commits: vec!["abc123".to_string(), "def456".to_string()],
360 source_ref: Some("feature-branch".to_string()),
361 timestamp: Utc::now(),
362 };
363
364 write_pending_squash(git_dir, &pending).unwrap();
365 let read_back = read_pending_squash(git_dir).unwrap().unwrap();
366
367 assert_eq!(read_back.source_commits, pending.source_commits);
368 assert_eq!(read_back.source_ref, pending.source_ref);
369 }
370
371 #[test]
372 fn test_pending_squash_missing_file() {
373 let dir = tempfile::tempdir().unwrap();
374 let result = read_pending_squash(dir.path()).unwrap();
375 assert!(result.is_none());
376 }
377
378 #[test]
379 fn test_pending_squash_stale_file() {
380 let dir = tempfile::tempdir().unwrap();
381 let git_dir = dir.path();
382 std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
383
384 let pending = PendingSquash {
385 source_commits: vec!["abc123".to_string()],
386 source_ref: None,
387 timestamp: Utc::now() - chrono::Duration::seconds(120),
388 };
389
390 write_pending_squash(git_dir, &pending).unwrap();
391 let result = read_pending_squash(git_dir).unwrap();
392 assert!(result.is_none());
393 assert!(!pending_squash_path(git_dir).exists());
395 }
396
397 #[test]
398 fn test_pending_squash_invalid_json() {
399 let dir = tempfile::tempdir().unwrap();
400 let git_dir = dir.path();
401 let chronicle_dir = git_dir.join("chronicle");
402 std::fs::create_dir_all(&chronicle_dir).unwrap();
403 std::fs::write(chronicle_dir.join("pending-squash.json"), "not json").unwrap();
404
405 let result = read_pending_squash(git_dir).unwrap();
406 assert!(result.is_none());
407 assert!(!pending_squash_path(git_dir).exists());
409 }
410
411 #[test]
412 fn test_delete_pending_squash() {
413 let dir = tempfile::tempdir().unwrap();
414 let git_dir = dir.path();
415
416 let pending = PendingSquash {
417 source_commits: vec!["abc123".to_string()],
418 source_ref: None,
419 timestamp: Utc::now(),
420 };
421
422 write_pending_squash(git_dir, &pending).unwrap();
423 assert!(pending_squash_path(git_dir).exists());
424
425 delete_pending_squash(git_dir).unwrap();
426 assert!(!pending_squash_path(git_dir).exists());
427 }
428
429 #[test]
430 fn test_delete_pending_squash_missing_file() {
431 let dir = tempfile::tempdir().unwrap();
432 delete_pending_squash(dir.path()).unwrap();
434 }
435
436 #[test]
437 fn test_synthesize_squash_distinct_regions() {
438 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
439 let ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
440 let ann3 = make_test_annotation("ghi789", "src/baz.rs", "baz_fn");
441
442 let ctx = SquashSynthesisContext {
443 squash_commit: "squash001".to_string(),
444 diff: "some diff".to_string(),
445 source_annotations: vec![ann1, ann2, ann3],
446 source_messages: vec![
447 ("abc123".to_string(), "Commit abc".to_string()),
448 ("def456".to_string(), "Commit def".to_string()),
449 ("ghi789".to_string(), "Commit ghi".to_string()),
450 ],
451 squash_message: "Squash merge".to_string(),
452 };
453
454 let result = synthesize_squash_annotation(&ctx);
455
456 assert_eq!(result.commit, "squash001");
457 assert_eq!(result.regions.len(), 3);
458 assert_eq!(result.cross_cutting.len(), 3);
459 assert_eq!(result.provenance.operation, ProvenanceOperation::Squash);
460 assert_eq!(result.provenance.derived_from.len(), 3);
461 assert!(result.provenance.original_annotations_preserved);
462 }
463
464 #[test]
465 fn test_synthesize_squash_overlapping_regions() {
466 let ann1 = make_test_annotation("abc123", "src/foo.rs", "connect");
467 let mut ann2 = make_test_annotation("def456", "src/foo.rs", "connect");
468 ann2.regions[0].constraints[0].text = "Constraint from def456".to_string();
470 ann2.regions[0].lines = LineRange { start: 5, end: 20 };
471
472 let ctx = SquashSynthesisContext {
473 squash_commit: "squash001".to_string(),
474 diff: "some diff".to_string(),
475 source_annotations: vec![ann1, ann2],
476 source_messages: vec![
477 ("abc123".to_string(), "First".to_string()),
478 ("def456".to_string(), "Second".to_string()),
479 ],
480 squash_message: "Squash merge".to_string(),
481 };
482
483 let result = synthesize_squash_annotation(&ctx);
484
485 assert_eq!(result.regions.len(), 1);
487 assert_eq!(result.regions[0].constraints.len(), 2);
489 assert_eq!(result.regions[0].lines.start, 1);
491 assert_eq!(result.regions[0].lines.end, 20);
492 assert!(result.regions[0]
494 .reasoning
495 .as_ref()
496 .unwrap()
497 .contains("abc123"));
498 assert!(result.regions[0]
499 .reasoning
500 .as_ref()
501 .unwrap()
502 .contains("def456"));
503 }
504
505 #[test]
506 fn test_synthesize_squash_partial_annotations() {
507 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
508
509 let ctx = SquashSynthesisContext {
510 squash_commit: "squash001".to_string(),
511 diff: "some diff".to_string(),
512 source_annotations: vec![ann1],
513 source_messages: vec![
514 ("abc123".to_string(), "First".to_string()),
515 ("def456".to_string(), "Second".to_string()),
516 ("ghi789".to_string(), "Third".to_string()),
517 ],
518 squash_message: "Squash merge".to_string(),
519 };
520
521 let result = synthesize_squash_annotation(&ctx);
522
523 assert!(!result.provenance.original_annotations_preserved);
524 assert!(result
525 .provenance
526 .synthesis_notes
527 .as_ref()
528 .unwrap()
529 .contains("1 of 3"));
530 }
531
532 #[test]
533 fn test_synthesize_squash_no_annotations() {
534 let ctx = SquashSynthesisContext {
535 squash_commit: "squash001".to_string(),
536 diff: "some diff".to_string(),
537 source_annotations: vec![],
538 source_messages: vec![
539 ("abc123".to_string(), "First".to_string()),
540 ("def456".to_string(), "Second".to_string()),
541 ],
542 squash_message: "Squash merge".to_string(),
543 };
544
545 let result = synthesize_squash_annotation(&ctx);
546
547 assert_eq!(result.context_level, ContextLevel::Inferred);
548 assert!(result.regions.is_empty());
549 assert!(!result.provenance.original_annotations_preserved);
550 }
551
552 #[test]
553 fn test_synthesize_preserves_cross_cutting() {
554 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
555 let mut ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
556 ann2.cross_cutting.push(CrossCuttingConcern {
558 description: "Another concern".to_string(),
559 regions: vec![CrossCuttingRegionRef {
560 file: "src/bar.rs".to_string(),
561 anchor: "bar_fn".to_string(),
562 }],
563 tags: Vec::new(),
564 });
565
566 let ctx = SquashSynthesisContext {
567 squash_commit: "squash001".to_string(),
568 diff: "some diff".to_string(),
569 source_annotations: vec![ann1, ann2],
570 source_messages: vec![
571 ("abc123".to_string(), "First".to_string()),
572 ("def456".to_string(), "Second".to_string()),
573 ],
574 squash_message: "Squash merge".to_string(),
575 };
576
577 let result = synthesize_squash_annotation(&ctx);
578 assert_eq!(result.cross_cutting.len(), 3);
580 }
581
582 #[test]
583 fn test_migrate_amend_message_only() {
584 let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
585
586 let ctx = AmendMigrationContext {
587 new_commit: "new_sha".to_string(),
588 new_diff: "".to_string(), old_annotation: old_ann,
590 new_message: "Updated commit message".to_string(),
591 };
592
593 let result = migrate_amend_annotation(&ctx);
594
595 assert_eq!(result.commit, "new_sha");
596 assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
597 assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
598 assert!(result.provenance.original_annotations_preserved);
599 assert!(result
600 .provenance
601 .synthesis_notes
602 .as_ref()
603 .unwrap()
604 .contains("Message-only"));
605 assert_eq!(result.summary, "Updated commit message");
606 assert_eq!(result.regions.len(), 1);
608 }
609
610 #[test]
611 fn test_migrate_amend_with_code_changes() {
612 let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
613
614 let ctx = AmendMigrationContext {
615 new_commit: "new_sha".to_string(),
616 new_diff: "+some new code\n-some old code\n".to_string(),
617 old_annotation: old_ann,
618 new_message: "Updated commit".to_string(),
619 };
620
621 let result = migrate_amend_annotation(&ctx);
622
623 assert_eq!(result.commit, "new_sha");
624 assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
625 assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
626 assert!(result
627 .provenance
628 .synthesis_notes
629 .as_ref()
630 .unwrap()
631 .contains("Migrated from amend"));
632 assert_eq!(result.regions.len(), 1);
634 }
635}