Skip to main content

chronicle/read/
summary.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::common::LineRange;
4use crate::schema::{self, v2};
5
6/// Query parameters for a condensed summary.
7#[derive(Debug, Clone)]
8pub struct SummaryQuery {
9    pub file: String,
10    pub anchor: Option<String>,
11}
12
13/// A summary unit for one AST element.
14#[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/// Anchor information in a summary unit.
27#[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/// Statistics about the summary query.
37#[derive(Debug, Clone, serde::Serialize)]
38pub struct SummaryStats {
39    pub regions_found: u32,
40    pub commits_examined: u32,
41}
42
43/// Output of a summary query.
44#[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/// Echo of the query parameters in the output.
53#[derive(Debug, Clone, serde::Serialize)]
54pub struct QueryEcho {
55    pub file: String,
56    pub anchor: Option<String>,
57}
58
59/// Accumulated state for a single anchor across markers.
60struct AnchorAccumulator {
61    anchor: SummaryAnchor,
62    lines: LineRange,
63    intent: String,
64    constraints: Vec<String>,
65    risk_notes: Option<String>,
66    timestamp: String,
67}
68
69/// Build a condensed summary for a file (or file+anchor).
70///
71/// Handles both v1 (migrated) and native v2 annotations:
72/// - Markers with anchors produce per-anchor units (contracts, hazards, deps)
73/// - Annotations touching the file contribute their narrative as file-level context
74/// - For each unique anchor, the most recent commit wins
75pub 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    // Key: anchor name -> AnchorAccumulator
80    // Within a single commit, markers for the same anchor are merged.
81    // Across commits, the first (newest) commit for each anchor wins.
82    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(&note) {
92            Ok(a) => a,
93            Err(e) => {
94                tracing::debug!("skipping malformed annotation for {sha}: {e}");
95                continue;
96            }
97        };
98
99        // Collect markers from this commit, grouped by anchor name
100        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            // Skip if we already have a newer entry for this anchor
123            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            // Merge markers within the same commit for the same anchor
147            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        // Only insert anchors from this commit that we haven't seen yet
226        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    // Sort by line start for deterministic output
243    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        // v1 regions with constraints and risk_notes migrate to markers,
379        // which produce summary units.
380        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        // The "main" anchor should have both contract and hazard markers aggregated
412        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        // Two commits with same anchor constraint. Newest first in git log.
424        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            // newest first (as git log returns)
463            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        // Verify that reasoning and tags don't appear in the output
481        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        // Should not contain "reasoning" or "tags" fields
514        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        // Test with a native v2 annotation (not migrated from v1)
590        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        // v1 regions with no constraints/risk/deps produce no markers,
669        // so they correctly produce no summary units (v2 summary is marker-based)
670        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        // No constraints/risk/deps = no markers = no units (this is expected in v2)
699        assert!(result.units.is_empty());
700    }
701}