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, v3};
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/// In v3, all metadata is in wisdom entries. This function groups wisdom
72/// by file and produces summary units with gotcha entries as constraints.
73pub 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    // Key: file -> AnchorAccumulator
78    // In v3 there are no named anchors in wisdom, so we group by file.
79    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(&note) {
89            Ok(a) => a,
90            Err(e) => {
91                tracing::debug!("skipping malformed annotation for {sha}: {e}");
92                continue;
93            }
94        };
95
96        // Group wisdom entries from this commit by file
97        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            // Skip if we already have a newer entry for this file key
112            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                    // Other wisdom categories contribute to risk_notes
141                    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    // Sort by line start for deterministic output
167    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        // v1 regions with constraints and risk_notes migrate to markers,
304        // which produce summary units.
305        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        // The "main" anchor should have both contract and hazard markers aggregated
337        assert_eq!(result.units.len(), 1);
338        assert_eq!(result.units[0].anchor.name, "src/main.rs"); // v3 groups by file, not anchor
339                                                                // Both v1 constraints and risk_notes become Gotcha wisdom entries (constraints)
340        assert_eq!(
341            result.units[0].constraints,
342            vec!["must not panic", "error handling is fragile"]
343        );
344        // No separate risk_notes since both are gotcha category in v3
345        assert_eq!(result.units[0].risk_notes, None);
346    }
347
348    #[test]
349    fn test_summary_keeps_most_recent_marker() {
350        // Two commits with same anchor constraint. Newest first in git log.
351        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            // newest first (as git log returns)
390            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        // Verify that reasoning and tags don't appear in the output
408        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        // Should not contain "reasoning" or "tags" fields
441        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        // v3 file-level grouping collapses both anchors into one unit for src/main.rs
510        assert_eq!(result.units.len(), 1);
511        assert_eq!(result.units[0].anchor.name, "src/main.rs");
512        // Both anchors' constraints merged into the file-level unit
513        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        // Test with a native v2 annotation (migrated to v3 at parse time)
522        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"); // v3 groups by file, not anchor
588        assert_eq!(result.units[0].intent, "Add caching layer");
589        // Both Contract and Hazard become Gotcha wisdom entries (constraints) in v3
590        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        // v1 regions with no constraints/risk/deps produce no markers,
603        // so they correctly produce no summary units (v2 summary is marker-based)
604        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        // No constraints/risk/deps = no markers = no units (this is expected in v2)
633        assert!(result.units.is_empty());
634    }
635}