1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::common::LineRange;
4use crate::schema::{self, v3};
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> {
74 let shas = git.log_for_file(&query.file)?;
75 let commits_examined = shas.len() as u32;
76
77 let mut best: std::collections::HashMap<String, AnchorAccumulator> =
80 std::collections::HashMap::new();
81
82 for sha in &shas {
83 let note = match git.note_read(sha)? {
84 Some(n) => n,
85 None => continue,
86 };
87
88 let annotation = match schema::parse_annotation(¬e) {
89 Ok(a) => a,
90 Err(e) => {
91 tracing::debug!("skipping malformed annotation for {sha}: {e}");
92 continue;
93 }
94 };
95
96 let mut commit_groups: std::collections::HashMap<String, AnchorAccumulator> =
98 std::collections::HashMap::new();
99
100 for w in &annotation.wisdom {
101 let entry_file = match &w.file {
102 Some(f) => f,
103 None => continue,
104 };
105 if !file_matches(entry_file, &query.file) {
106 continue;
107 }
108
109 let key = entry_file.clone();
110
111 if best.contains_key(&key) {
113 continue;
114 }
115
116 let lines = w.lines.unwrap_or(LineRange { start: 0, end: 0 });
117
118 let acc = commit_groups
119 .entry(key)
120 .or_insert_with(|| AnchorAccumulator {
121 anchor: SummaryAnchor {
122 unit_type: "file".to_string(),
123 name: entry_file.clone(),
124 signature: None,
125 },
126 lines,
127 intent: annotation.summary.clone(),
128 constraints: vec![],
129 risk_notes: None,
130 timestamp: annotation.timestamp.clone(),
131 });
132
133 match w.category {
134 v3::WisdomCategory::Gotcha => {
135 if !acc.constraints.contains(&w.content) {
136 acc.constraints.push(w.content.clone());
137 }
138 }
139 _ => {
140 let note = w.content.clone();
142 acc.risk_notes = Some(match acc.risk_notes.take() {
143 Some(existing) => format!("{existing}; {note}"),
144 None => note,
145 });
146 }
147 }
148 }
149
150 for (key, acc) in commit_groups {
151 best.entry(key).or_insert(acc);
152 }
153 }
154
155 let mut units: Vec<SummaryUnit> = best
156 .into_values()
157 .map(|acc| SummaryUnit {
158 anchor: acc.anchor,
159 lines: acc.lines,
160 intent: acc.intent,
161 constraints: acc.constraints,
162 risk_notes: acc.risk_notes,
163 last_modified: acc.timestamp,
164 })
165 .collect();
166 units.sort_by_key(|u| u.lines.start);
168
169 let regions_found = units.len() as u32;
170
171 Ok(SummaryOutput {
172 schema: "chronicle-summary/v1".to_string(),
173 query: QueryEcho {
174 file: query.file.clone(),
175 anchor: query.anchor.clone(),
176 },
177 units,
178 stats: SummaryStats {
179 regions_found,
180 commits_examined,
181 },
182 })
183}
184
185use super::matching::file_matches;
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::schema::common::{AstAnchor, LineRange};
191 use crate::schema::v1::{
192 self, Constraint, ConstraintSource, ContextLevel, Provenance, ProvenanceOperation,
193 RegionAnnotation,
194 };
195 use crate::schema::v2;
196 type Annotation = v1::Annotation;
197
198 struct MockGitOps {
199 file_log: Vec<String>,
200 notes: std::collections::HashMap<String, String>,
201 }
202
203 impl GitOps for MockGitOps {
204 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
205 Ok(vec![])
206 }
207 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
208 Ok(self.notes.get(commit).cloned())
209 }
210 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
211 Ok(())
212 }
213 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
214 Ok(self.notes.contains_key(commit))
215 }
216 fn file_at_commit(
217 &self,
218 _path: &std::path::Path,
219 _commit: &str,
220 ) -> Result<String, GitError> {
221 Ok(String::new())
222 }
223 fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
224 Ok(crate::git::CommitInfo {
225 sha: "abc123".to_string(),
226 message: "test".to_string(),
227 author_name: "test".to_string(),
228 author_email: "test@test.com".to_string(),
229 timestamp: "2025-01-01T00:00:00Z".to_string(),
230 parent_shas: vec![],
231 })
232 }
233 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
234 Ok("abc123".to_string())
235 }
236 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
237 Ok(None)
238 }
239 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
240 Ok(())
241 }
242 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
243 Ok(self.file_log.clone())
244 }
245 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
246 Ok(vec![])
247 }
248 }
249
250 fn make_annotation(
251 commit: &str,
252 timestamp: &str,
253 regions: Vec<RegionAnnotation>,
254 ) -> Annotation {
255 Annotation {
256 schema: "chronicle/v1".to_string(),
257 commit: commit.to_string(),
258 timestamp: timestamp.to_string(),
259 task: None,
260 summary: "test".to_string(),
261 context_level: ContextLevel::Enhanced,
262 regions,
263 cross_cutting: vec![],
264 provenance: Provenance {
265 operation: ProvenanceOperation::Initial,
266 derived_from: vec![],
267 original_annotations_preserved: false,
268 synthesis_notes: None,
269 },
270 }
271 }
272
273 fn make_region(
274 file: &str,
275 anchor: &str,
276 unit_type: &str,
277 lines: LineRange,
278 _intent: &str,
279 constraints: Vec<Constraint>,
280 risk_notes: Option<&str>,
281 ) -> RegionAnnotation {
282 RegionAnnotation {
283 file: file.to_string(),
284 ast_anchor: AstAnchor {
285 unit_type: unit_type.to_string(),
286 name: anchor.to_string(),
287 signature: None,
288 },
289 lines,
290 intent: "test intent".to_string(),
291 reasoning: Some("detailed reasoning".to_string()),
292 constraints,
293 semantic_dependencies: vec![],
294 related_annotations: vec![],
295 tags: vec!["tag1".to_string()],
296 risk_notes: risk_notes.map(|s| s.to_string()),
297 corrections: vec![],
298 }
299 }
300
301 #[test]
302 fn test_summary_with_constraints_and_risk() {
303 let ann = make_annotation(
306 "commit1",
307 "2025-01-01T00:00:00Z",
308 vec![make_region(
309 "src/main.rs",
310 "main",
311 "fn",
312 LineRange { start: 1, end: 10 },
313 "entry point",
314 vec![Constraint {
315 text: "must not panic".to_string(),
316 source: ConstraintSource::Author,
317 }],
318 Some("error handling is fragile"),
319 )],
320 );
321
322 let mut notes = std::collections::HashMap::new();
323 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
324
325 let git = MockGitOps {
326 file_log: vec!["commit1".to_string()],
327 notes,
328 };
329
330 let query = SummaryQuery {
331 file: "src/main.rs".to_string(),
332 anchor: None,
333 };
334
335 let result = build_summary(&git, &query).unwrap();
336 assert_eq!(result.units.len(), 1);
338 assert_eq!(result.units[0].anchor.name, "src/main.rs"); assert_eq!(
341 result.units[0].constraints,
342 vec!["must not panic", "error handling is fragile"]
343 );
344 assert_eq!(result.units[0].risk_notes, None);
346 }
347
348 #[test]
349 fn test_summary_keeps_most_recent_marker() {
350 let ann1 = make_annotation(
352 "commit1",
353 "2025-01-01T00:00:00Z",
354 vec![make_region(
355 "src/main.rs",
356 "main",
357 "fn",
358 LineRange { start: 1, end: 10 },
359 "",
360 vec![Constraint {
361 text: "old constraint".to_string(),
362 source: ConstraintSource::Author,
363 }],
364 None,
365 )],
366 );
367 let ann2 = make_annotation(
368 "commit2",
369 "2025-01-02T00:00:00Z",
370 vec![make_region(
371 "src/main.rs",
372 "main",
373 "fn",
374 LineRange { start: 1, end: 10 },
375 "",
376 vec![Constraint {
377 text: "new constraint".to_string(),
378 source: ConstraintSource::Author,
379 }],
380 None,
381 )],
382 );
383
384 let mut notes = std::collections::HashMap::new();
385 notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
386 notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
387
388 let git = MockGitOps {
389 file_log: vec!["commit2".to_string(), "commit1".to_string()],
391 notes,
392 };
393
394 let query = SummaryQuery {
395 file: "src/main.rs".to_string(),
396 anchor: None,
397 };
398
399 let result = build_summary(&git, &query).unwrap();
400 assert_eq!(result.units.len(), 1);
401 assert_eq!(result.units[0].constraints, vec!["new constraint"]);
402 assert_eq!(result.units[0].last_modified, "2025-01-02T00:00:00Z");
403 }
404
405 #[test]
406 fn test_summary_only_intent_constraints_risk() {
407 let ann = make_annotation(
409 "commit1",
410 "2025-01-01T00:00:00Z",
411 vec![make_region(
412 "src/main.rs",
413 "main",
414 "fn",
415 LineRange { start: 1, end: 10 },
416 "entry point",
417 vec![Constraint {
418 text: "must be fast".to_string(),
419 source: ConstraintSource::Inferred,
420 }],
421 None,
422 )],
423 );
424
425 let mut notes = std::collections::HashMap::new();
426 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
427
428 let git = MockGitOps {
429 file_log: vec!["commit1".to_string()],
430 notes,
431 };
432
433 let query = SummaryQuery {
434 file: "src/main.rs".to_string(),
435 anchor: None,
436 };
437
438 let result = build_summary(&git, &query).unwrap();
439 let json = serde_json::to_string(&result).unwrap();
440 assert!(!json.contains("\"reasoning\""));
442 assert!(!json.contains("\"tags\""));
443 }
444
445 #[test]
446 fn test_summary_empty_when_no_annotations() {
447 let git = MockGitOps {
448 file_log: vec!["commit1".to_string()],
449 notes: std::collections::HashMap::new(),
450 };
451
452 let query = SummaryQuery {
453 file: "src/main.rs".to_string(),
454 anchor: None,
455 };
456
457 let result = build_summary(&git, &query).unwrap();
458 assert!(result.units.is_empty());
459 assert_eq!(result.stats.regions_found, 0);
460 }
461
462 #[test]
463 fn test_summary_with_anchor_filter() {
464 let ann = make_annotation(
465 "commit1",
466 "2025-01-01T00:00:00Z",
467 vec![
468 make_region(
469 "src/main.rs",
470 "main",
471 "fn",
472 LineRange { start: 1, end: 10 },
473 "",
474 vec![Constraint {
475 text: "must not panic".to_string(),
476 source: ConstraintSource::Author,
477 }],
478 None,
479 ),
480 make_region(
481 "src/main.rs",
482 "helper",
483 "fn",
484 LineRange { start: 12, end: 20 },
485 "",
486 vec![Constraint {
487 text: "must be pure".to_string(),
488 source: ConstraintSource::Inferred,
489 }],
490 None,
491 ),
492 ],
493 );
494
495 let mut notes = std::collections::HashMap::new();
496 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
497
498 let git = MockGitOps {
499 file_log: vec!["commit1".to_string()],
500 notes,
501 };
502
503 let query = SummaryQuery {
504 file: "src/main.rs".to_string(),
505 anchor: Some("main".to_string()),
506 };
507
508 let result = build_summary(&git, &query).unwrap();
509 assert_eq!(result.units.len(), 1);
511 assert_eq!(result.units[0].anchor.name, "src/main.rs");
512 assert_eq!(
514 result.units[0].constraints,
515 vec!["must not panic", "must be pure"]
516 );
517 }
518
519 #[test]
520 fn test_summary_native_v2_annotation() {
521 let v2_ann = v2::Annotation {
523 schema: "chronicle/v2".to_string(),
524 commit: "commit1".to_string(),
525 timestamp: "2025-01-01T00:00:00Z".to_string(),
526 narrative: v2::Narrative {
527 summary: "Add caching layer".to_string(),
528 motivation: None,
529 rejected_alternatives: vec![],
530 follow_up: None,
531 files_changed: vec!["src/cache.rs".to_string()],
532 sentiments: vec![],
533 },
534 decisions: vec![],
535 markers: vec![
536 v2::CodeMarker {
537 file: "src/cache.rs".to_string(),
538 anchor: Some(AstAnchor {
539 unit_type: "function".to_string(),
540 name: "Cache::get".to_string(),
541 signature: None,
542 }),
543 lines: Some(LineRange { start: 10, end: 20 }),
544 kind: v2::MarkerKind::Contract {
545 description: "Must return None for expired entries".to_string(),
546 source: v2::ContractSource::Author,
547 },
548 },
549 v2::CodeMarker {
550 file: "src/cache.rs".to_string(),
551 anchor: Some(AstAnchor {
552 unit_type: "function".to_string(),
553 name: "Cache::get".to_string(),
554 signature: None,
555 }),
556 lines: Some(LineRange { start: 10, end: 20 }),
557 kind: v2::MarkerKind::Hazard {
558 description: "Not thread-safe without external locking".to_string(),
559 },
560 },
561 ],
562 effort: None,
563 provenance: v2::Provenance {
564 source: v2::ProvenanceSource::Live,
565 author: None,
566 derived_from: vec![],
567 notes: None,
568 },
569 };
570 let note = serde_json::to_string(&v2_ann).unwrap();
571
572 let mut notes = std::collections::HashMap::new();
573 notes.insert("commit1".to_string(), note);
574
575 let git = MockGitOps {
576 file_log: vec!["commit1".to_string()],
577 notes,
578 };
579
580 let query = SummaryQuery {
581 file: "src/cache.rs".to_string(),
582 anchor: None,
583 };
584
585 let result = build_summary(&git, &query).unwrap();
586 assert_eq!(result.units.len(), 1);
587 assert_eq!(result.units[0].anchor.name, "src/cache.rs"); assert_eq!(result.units[0].intent, "Add caching layer");
589 assert_eq!(
591 result.units[0].constraints,
592 vec![
593 "Must return None for expired entries",
594 "Not thread-safe without external locking"
595 ]
596 );
597 assert_eq!(result.units[0].risk_notes, None);
598 }
599
600 #[test]
601 fn test_summary_no_markers_no_units() {
602 let ann = make_annotation(
605 "commit1",
606 "2025-01-01T00:00:00Z",
607 vec![make_region(
608 "src/main.rs",
609 "main",
610 "fn",
611 LineRange { start: 1, end: 10 },
612 "entry point",
613 vec![],
614 None,
615 )],
616 );
617
618 let mut notes = std::collections::HashMap::new();
619 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
620
621 let git = MockGitOps {
622 file_log: vec!["commit1".to_string()],
623 notes,
624 };
625
626 let query = SummaryQuery {
627 file: "src/main.rs".to_string(),
628 anchor: None,
629 };
630
631 let result = build_summary(&git, &query).unwrap();
632 assert!(result.units.is_empty());
634 }
635}