1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::common::LineRange;
4use crate::schema::{self, v2};
5
6#[derive(Debug, Clone)]
8pub struct SummaryQuery {
9 pub file: String,
10 pub anchor: Option<String>,
11}
12
13#[derive(Debug, Clone, serde::Serialize)]
15pub struct SummaryUnit {
16 pub anchor: SummaryAnchor,
17 pub lines: LineRange,
18 pub intent: String,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub constraints: Vec<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub risk_notes: Option<String>,
23 pub last_modified: String,
24}
25
26#[derive(Debug, Clone, serde::Serialize)]
28pub struct SummaryAnchor {
29 #[serde(rename = "type")]
30 pub unit_type: String,
31 pub name: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub signature: Option<String>,
34}
35
36#[derive(Debug, Clone, serde::Serialize)]
38pub struct SummaryStats {
39 pub regions_found: u32,
40 pub commits_examined: u32,
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
45pub struct SummaryOutput {
46 pub schema: String,
47 pub query: QueryEcho,
48 pub units: Vec<SummaryUnit>,
49 pub stats: SummaryStats,
50}
51
52#[derive(Debug, Clone, serde::Serialize)]
54pub struct QueryEcho {
55 pub file: String,
56 pub anchor: Option<String>,
57}
58
59struct AnchorAccumulator {
61 anchor: SummaryAnchor,
62 lines: LineRange,
63 intent: String,
64 constraints: Vec<String>,
65 risk_notes: Option<String>,
66 timestamp: String,
67}
68
69pub fn build_summary(git: &dyn GitOps, query: &SummaryQuery) -> Result<SummaryOutput, GitError> {
76 let shas = git.log_for_file(&query.file)?;
77 let commits_examined = shas.len() as u32;
78
79 let mut best: std::collections::HashMap<String, AnchorAccumulator> =
83 std::collections::HashMap::new();
84
85 for sha in &shas {
86 let note = match git.note_read(sha)? {
87 Some(n) => n,
88 None => continue,
89 };
90
91 let annotation: v2::Annotation = match schema::parse_annotation(¬e) {
92 Ok(a) => a,
93 Err(e) => {
94 tracing::debug!("skipping malformed annotation for {sha}: {e}");
95 continue;
96 }
97 };
98
99 let mut commit_anchors: std::collections::HashMap<String, AnchorAccumulator> =
101 std::collections::HashMap::new();
102
103 for marker in &annotation.markers {
104 if !file_matches(&marker.file, &query.file) {
105 continue;
106 }
107
108 let anchor_name = marker
109 .anchor
110 .as_ref()
111 .map(|a| a.name.as_str())
112 .unwrap_or("");
113
114 if let Some(ref query_anchor) = query.anchor {
115 if !anchor_matches(anchor_name, query_anchor) {
116 continue;
117 }
118 }
119
120 let key = anchor_name.to_string();
121
122 if best.contains_key(&key) {
124 continue;
125 }
126
127 let (anchor_info, lines) = match &marker.anchor {
128 Some(anchor) => (
129 SummaryAnchor {
130 unit_type: anchor.unit_type.clone(),
131 name: anchor.name.clone(),
132 signature: anchor.signature.clone(),
133 },
134 marker.lines.unwrap_or(LineRange { start: 0, end: 0 }),
135 ),
136 None => (
137 SummaryAnchor {
138 unit_type: "file".to_string(),
139 name: marker.file.clone(),
140 signature: None,
141 },
142 marker.lines.unwrap_or(LineRange { start: 0, end: 0 }),
143 ),
144 };
145
146 let acc = commit_anchors
148 .entry(key)
149 .or_insert_with(|| AnchorAccumulator {
150 anchor: anchor_info,
151 lines,
152 intent: annotation.narrative.summary.clone(),
153 constraints: vec![],
154 risk_notes: None,
155 timestamp: annotation.timestamp.clone(),
156 });
157
158 match &marker.kind {
159 v2::MarkerKind::Contract { description, .. } => {
160 if !acc.constraints.contains(description) {
161 acc.constraints.push(description.clone());
162 }
163 }
164 v2::MarkerKind::Hazard { description } => {
165 acc.risk_notes = Some(description.clone());
166 }
167 v2::MarkerKind::Dependency {
168 assumption,
169 target_file,
170 target_anchor,
171 ..
172 } => {
173 let dep_note =
174 format!("depends on {target_file}:{target_anchor}: {assumption}");
175 acc.risk_notes = Some(match acc.risk_notes.take() {
176 Some(existing) => format!("{existing}; {dep_note}"),
177 None => dep_note,
178 });
179 }
180 v2::MarkerKind::Unstable { description, .. } => {
181 let unstable_note = format!("UNSTABLE: {description}");
182 acc.risk_notes = Some(match acc.risk_notes.take() {
183 Some(existing) => format!("{existing}; {unstable_note}"),
184 None => unstable_note,
185 });
186 }
187 v2::MarkerKind::Security { description } => {
188 let note = format!("SECURITY: {description}");
189 acc.risk_notes = Some(match acc.risk_notes.take() {
190 Some(existing) => format!("{existing}; {note}"),
191 None => note,
192 });
193 }
194 v2::MarkerKind::Performance { description } => {
195 let note = format!("PERF: {description}");
196 acc.risk_notes = Some(match acc.risk_notes.take() {
197 Some(existing) => format!("{existing}; {note}"),
198 None => note,
199 });
200 }
201 v2::MarkerKind::Deprecated { description, .. } => {
202 let note = format!("DEPRECATED: {description}");
203 acc.risk_notes = Some(match acc.risk_notes.take() {
204 Some(existing) => format!("{existing}; {note}"),
205 None => note,
206 });
207 }
208 v2::MarkerKind::TechDebt { description } => {
209 let note = format!("TECH_DEBT: {description}");
210 acc.risk_notes = Some(match acc.risk_notes.take() {
211 Some(existing) => format!("{existing}; {note}"),
212 None => note,
213 });
214 }
215 v2::MarkerKind::TestCoverage { description } => {
216 let note = format!("TEST_COVERAGE: {description}");
217 acc.risk_notes = Some(match acc.risk_notes.take() {
218 Some(existing) => format!("{existing}; {note}"),
219 None => note,
220 });
221 }
222 }
223 }
224
225 for (key, acc) in commit_anchors {
227 best.entry(key).or_insert(acc);
228 }
229 }
230
231 let mut units: Vec<SummaryUnit> = best
232 .into_values()
233 .map(|acc| SummaryUnit {
234 anchor: acc.anchor,
235 lines: acc.lines,
236 intent: acc.intent,
237 constraints: acc.constraints,
238 risk_notes: acc.risk_notes,
239 last_modified: acc.timestamp,
240 })
241 .collect();
242 units.sort_by_key(|u| u.lines.start);
244
245 let regions_found = units.len() as u32;
246
247 Ok(SummaryOutput {
248 schema: "chronicle-summary/v1".to_string(),
249 query: QueryEcho {
250 file: query.file.clone(),
251 anchor: query.anchor.clone(),
252 },
253 units,
254 stats: SummaryStats {
255 regions_found,
256 commits_examined,
257 },
258 })
259}
260
261use super::matching::{anchor_matches, file_matches};
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::schema::common::{AstAnchor, LineRange};
267 use crate::schema::v1::{
268 self, Constraint, ConstraintSource, ContextLevel, Provenance, ProvenanceOperation,
269 RegionAnnotation,
270 };
271 type Annotation = v1::Annotation;
272
273 struct MockGitOps {
274 file_log: Vec<String>,
275 notes: std::collections::HashMap<String, String>,
276 }
277
278 impl GitOps for MockGitOps {
279 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
280 Ok(vec![])
281 }
282 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
283 Ok(self.notes.get(commit).cloned())
284 }
285 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
286 Ok(())
287 }
288 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
289 Ok(self.notes.contains_key(commit))
290 }
291 fn file_at_commit(
292 &self,
293 _path: &std::path::Path,
294 _commit: &str,
295 ) -> Result<String, GitError> {
296 Ok(String::new())
297 }
298 fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
299 Ok(crate::git::CommitInfo {
300 sha: "abc123".to_string(),
301 message: "test".to_string(),
302 author_name: "test".to_string(),
303 author_email: "test@test.com".to_string(),
304 timestamp: "2025-01-01T00:00:00Z".to_string(),
305 parent_shas: vec![],
306 })
307 }
308 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
309 Ok("abc123".to_string())
310 }
311 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
312 Ok(None)
313 }
314 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
315 Ok(())
316 }
317 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
318 Ok(self.file_log.clone())
319 }
320 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
321 Ok(vec![])
322 }
323 }
324
325 fn make_annotation(
326 commit: &str,
327 timestamp: &str,
328 regions: Vec<RegionAnnotation>,
329 ) -> Annotation {
330 Annotation {
331 schema: "chronicle/v1".to_string(),
332 commit: commit.to_string(),
333 timestamp: timestamp.to_string(),
334 task: None,
335 summary: "test".to_string(),
336 context_level: ContextLevel::Enhanced,
337 regions,
338 cross_cutting: vec![],
339 provenance: Provenance {
340 operation: ProvenanceOperation::Initial,
341 derived_from: vec![],
342 original_annotations_preserved: false,
343 synthesis_notes: None,
344 },
345 }
346 }
347
348 fn make_region(
349 file: &str,
350 anchor: &str,
351 unit_type: &str,
352 lines: LineRange,
353 _intent: &str,
354 constraints: Vec<Constraint>,
355 risk_notes: Option<&str>,
356 ) -> RegionAnnotation {
357 RegionAnnotation {
358 file: file.to_string(),
359 ast_anchor: AstAnchor {
360 unit_type: unit_type.to_string(),
361 name: anchor.to_string(),
362 signature: None,
363 },
364 lines,
365 intent: "test intent".to_string(),
366 reasoning: Some("detailed reasoning".to_string()),
367 constraints,
368 semantic_dependencies: vec![],
369 related_annotations: vec![],
370 tags: vec!["tag1".to_string()],
371 risk_notes: risk_notes.map(|s| s.to_string()),
372 corrections: vec![],
373 }
374 }
375
376 #[test]
377 fn test_summary_with_constraints_and_risk() {
378 let ann = make_annotation(
381 "commit1",
382 "2025-01-01T00:00:00Z",
383 vec![make_region(
384 "src/main.rs",
385 "main",
386 "fn",
387 LineRange { start: 1, end: 10 },
388 "entry point",
389 vec![Constraint {
390 text: "must not panic".to_string(),
391 source: ConstraintSource::Author,
392 }],
393 Some("error handling is fragile"),
394 )],
395 );
396
397 let mut notes = std::collections::HashMap::new();
398 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
399
400 let git = MockGitOps {
401 file_log: vec!["commit1".to_string()],
402 notes,
403 };
404
405 let query = SummaryQuery {
406 file: "src/main.rs".to_string(),
407 anchor: None,
408 };
409
410 let result = build_summary(&git, &query).unwrap();
411 assert_eq!(result.units.len(), 1);
413 assert_eq!(result.units[0].anchor.name, "main");
414 assert_eq!(result.units[0].constraints, vec!["must not panic"]);
415 assert_eq!(
416 result.units[0].risk_notes,
417 Some("error handling is fragile".to_string())
418 );
419 }
420
421 #[test]
422 fn test_summary_keeps_most_recent_marker() {
423 let ann1 = make_annotation(
425 "commit1",
426 "2025-01-01T00:00:00Z",
427 vec![make_region(
428 "src/main.rs",
429 "main",
430 "fn",
431 LineRange { start: 1, end: 10 },
432 "",
433 vec![Constraint {
434 text: "old constraint".to_string(),
435 source: ConstraintSource::Author,
436 }],
437 None,
438 )],
439 );
440 let ann2 = make_annotation(
441 "commit2",
442 "2025-01-02T00:00:00Z",
443 vec![make_region(
444 "src/main.rs",
445 "main",
446 "fn",
447 LineRange { start: 1, end: 10 },
448 "",
449 vec![Constraint {
450 text: "new constraint".to_string(),
451 source: ConstraintSource::Author,
452 }],
453 None,
454 )],
455 );
456
457 let mut notes = std::collections::HashMap::new();
458 notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
459 notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
460
461 let git = MockGitOps {
462 file_log: vec!["commit2".to_string(), "commit1".to_string()],
464 notes,
465 };
466
467 let query = SummaryQuery {
468 file: "src/main.rs".to_string(),
469 anchor: None,
470 };
471
472 let result = build_summary(&git, &query).unwrap();
473 assert_eq!(result.units.len(), 1);
474 assert_eq!(result.units[0].constraints, vec!["new constraint"]);
475 assert_eq!(result.units[0].last_modified, "2025-01-02T00:00:00Z");
476 }
477
478 #[test]
479 fn test_summary_only_intent_constraints_risk() {
480 let ann = make_annotation(
482 "commit1",
483 "2025-01-01T00:00:00Z",
484 vec![make_region(
485 "src/main.rs",
486 "main",
487 "fn",
488 LineRange { start: 1, end: 10 },
489 "entry point",
490 vec![Constraint {
491 text: "must be fast".to_string(),
492 source: ConstraintSource::Inferred,
493 }],
494 None,
495 )],
496 );
497
498 let mut notes = std::collections::HashMap::new();
499 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
500
501 let git = MockGitOps {
502 file_log: vec!["commit1".to_string()],
503 notes,
504 };
505
506 let query = SummaryQuery {
507 file: "src/main.rs".to_string(),
508 anchor: None,
509 };
510
511 let result = build_summary(&git, &query).unwrap();
512 let json = serde_json::to_string(&result).unwrap();
513 assert!(!json.contains("\"reasoning\""));
515 assert!(!json.contains("\"tags\""));
516 }
517
518 #[test]
519 fn test_summary_empty_when_no_annotations() {
520 let git = MockGitOps {
521 file_log: vec!["commit1".to_string()],
522 notes: std::collections::HashMap::new(),
523 };
524
525 let query = SummaryQuery {
526 file: "src/main.rs".to_string(),
527 anchor: None,
528 };
529
530 let result = build_summary(&git, &query).unwrap();
531 assert!(result.units.is_empty());
532 assert_eq!(result.stats.regions_found, 0);
533 }
534
535 #[test]
536 fn test_summary_with_anchor_filter() {
537 let ann = make_annotation(
538 "commit1",
539 "2025-01-01T00:00:00Z",
540 vec![
541 make_region(
542 "src/main.rs",
543 "main",
544 "fn",
545 LineRange { start: 1, end: 10 },
546 "",
547 vec![Constraint {
548 text: "must not panic".to_string(),
549 source: ConstraintSource::Author,
550 }],
551 None,
552 ),
553 make_region(
554 "src/main.rs",
555 "helper",
556 "fn",
557 LineRange { start: 12, end: 20 },
558 "",
559 vec![Constraint {
560 text: "must be pure".to_string(),
561 source: ConstraintSource::Inferred,
562 }],
563 None,
564 ),
565 ],
566 );
567
568 let mut notes = std::collections::HashMap::new();
569 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
570
571 let git = MockGitOps {
572 file_log: vec!["commit1".to_string()],
573 notes,
574 };
575
576 let query = SummaryQuery {
577 file: "src/main.rs".to_string(),
578 anchor: Some("main".to_string()),
579 };
580
581 let result = build_summary(&git, &query).unwrap();
582 assert_eq!(result.units.len(), 1);
583 assert_eq!(result.units[0].anchor.name, "main");
584 assert_eq!(result.units[0].constraints, vec!["must not panic"]);
585 }
586
587 #[test]
588 fn test_summary_native_v2_annotation() {
589 let v2_ann = v2::Annotation {
591 schema: "chronicle/v2".to_string(),
592 commit: "commit1".to_string(),
593 timestamp: "2025-01-01T00:00:00Z".to_string(),
594 narrative: v2::Narrative {
595 summary: "Add caching layer".to_string(),
596 motivation: None,
597 rejected_alternatives: vec![],
598 follow_up: None,
599 files_changed: vec!["src/cache.rs".to_string()],
600 },
601 decisions: vec![],
602 markers: vec![
603 v2::CodeMarker {
604 file: "src/cache.rs".to_string(),
605 anchor: Some(AstAnchor {
606 unit_type: "function".to_string(),
607 name: "Cache::get".to_string(),
608 signature: None,
609 }),
610 lines: Some(LineRange { start: 10, end: 20 }),
611 kind: v2::MarkerKind::Contract {
612 description: "Must return None for expired entries".to_string(),
613 source: v2::ContractSource::Author,
614 },
615 },
616 v2::CodeMarker {
617 file: "src/cache.rs".to_string(),
618 anchor: Some(AstAnchor {
619 unit_type: "function".to_string(),
620 name: "Cache::get".to_string(),
621 signature: None,
622 }),
623 lines: Some(LineRange { start: 10, end: 20 }),
624 kind: v2::MarkerKind::Hazard {
625 description: "Not thread-safe without external locking".to_string(),
626 },
627 },
628 ],
629 effort: None,
630 provenance: v2::Provenance {
631 source: v2::ProvenanceSource::Live,
632 author: None,
633 derived_from: vec![],
634 notes: None,
635 },
636 };
637 let note = serde_json::to_string(&v2_ann).unwrap();
638
639 let mut notes = std::collections::HashMap::new();
640 notes.insert("commit1".to_string(), note);
641
642 let git = MockGitOps {
643 file_log: vec!["commit1".to_string()],
644 notes,
645 };
646
647 let query = SummaryQuery {
648 file: "src/cache.rs".to_string(),
649 anchor: None,
650 };
651
652 let result = build_summary(&git, &query).unwrap();
653 assert_eq!(result.units.len(), 1);
654 assert_eq!(result.units[0].anchor.name, "Cache::get");
655 assert_eq!(result.units[0].intent, "Add caching layer");
656 assert_eq!(
657 result.units[0].constraints,
658 vec!["Must return None for expired entries"]
659 );
660 assert_eq!(
661 result.units[0].risk_notes,
662 Some("Not thread-safe without external locking".to_string())
663 );
664 }
665
666 #[test]
667 fn test_summary_no_markers_no_units() {
668 let ann = make_annotation(
671 "commit1",
672 "2025-01-01T00:00:00Z",
673 vec![make_region(
674 "src/main.rs",
675 "main",
676 "fn",
677 LineRange { start: 1, end: 10 },
678 "entry point",
679 vec![],
680 None,
681 )],
682 );
683
684 let mut notes = std::collections::HashMap::new();
685 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
686
687 let git = MockGitOps {
688 file_log: vec!["commit1".to_string()],
689 notes,
690 };
691
692 let query = SummaryQuery {
693 file: "src/main.rs".to_string(),
694 anchor: None,
695 };
696
697 let result = build_summary(&git, &query).unwrap();
698 assert!(result.units.is_empty());
700 }
701}