Skip to main content

chronicle/read/
summary.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::annotation::{Annotation, LineRange};
4
5/// Query parameters for a condensed summary.
6#[derive(Debug, Clone)]
7pub struct SummaryQuery {
8    pub file: String,
9    pub anchor: Option<String>,
10}
11
12/// A summary unit for one AST element.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct SummaryUnit {
15    pub anchor: SummaryAnchor,
16    pub lines: LineRange,
17    pub intent: String,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub constraints: Vec<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub risk_notes: Option<String>,
22    pub last_modified: String,
23}
24
25/// Anchor information in a summary unit.
26#[derive(Debug, Clone, serde::Serialize)]
27pub struct SummaryAnchor {
28    #[serde(rename = "type")]
29    pub unit_type: String,
30    pub name: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub signature: Option<String>,
33}
34
35/// Statistics about the summary query.
36#[derive(Debug, Clone, serde::Serialize)]
37pub struct SummaryStats {
38    pub regions_found: u32,
39    pub commits_examined: u32,
40}
41
42/// Output of a summary query.
43#[derive(Debug, Clone, serde::Serialize)]
44pub struct SummaryOutput {
45    pub schema: String,
46    pub query: QueryEcho,
47    pub units: Vec<SummaryUnit>,
48    pub stats: SummaryStats,
49}
50
51/// Echo of the query parameters in the output.
52#[derive(Debug, Clone, serde::Serialize)]
53pub struct QueryEcho {
54    pub file: String,
55    pub anchor: Option<String>,
56}
57
58/// Build a condensed summary for a file (or file+anchor).
59///
60/// 1. Get commits that touched the file via `log_for_file`
61/// 2. For each commit, fetch annotation and filter to matching regions
62/// 3. For each unique anchor, keep the most recent annotation
63/// 4. Extract only intent, constraints, risk_notes
64pub fn build_summary(git: &dyn GitOps, query: &SummaryQuery) -> Result<SummaryOutput, GitError> {
65    let shas = git.log_for_file(&query.file)?;
66    let commits_examined = shas.len() as u32;
67
68    // Collect all matching regions with their timestamps.
69    // Key: anchor name, Value: (timestamp, SummaryUnit)
70    let mut best: std::collections::HashMap<String, (String, SummaryUnit)> =
71        std::collections::HashMap::new();
72
73    for sha in &shas {
74        let note = match git.note_read(sha)? {
75            Some(n) => n,
76            None => continue,
77        };
78
79        let annotation: Annotation = match serde_json::from_str(&note) {
80            Ok(a) => a,
81            Err(_) => continue,
82        };
83
84        for region in &annotation.regions {
85            if !file_matches(&region.file, &query.file) {
86                continue;
87            }
88            if let Some(ref anchor_name) = query.anchor {
89                if !anchor_matches(&region.ast_anchor.name, anchor_name) {
90                    continue;
91                }
92            }
93
94            let key = region.ast_anchor.name.clone();
95            let constraints: Vec<String> =
96                region.constraints.iter().map(|c| c.text.clone()).collect();
97
98            let unit = SummaryUnit {
99                anchor: SummaryAnchor {
100                    unit_type: region.ast_anchor.unit_type.clone(),
101                    name: region.ast_anchor.name.clone(),
102                    signature: region.ast_anchor.signature.clone(),
103                },
104                lines: region.lines,
105                intent: region.intent.clone(),
106                constraints,
107                risk_notes: region.risk_notes.clone(),
108                last_modified: annotation.timestamp.clone(),
109            };
110
111            // Keep the entry with the most recent (lexicographically largest) timestamp.
112            // Since git log returns newest first, the first match per anchor wins.
113            best.entry(key)
114                .or_insert((annotation.timestamp.clone(), unit));
115        }
116    }
117
118    let mut units: Vec<SummaryUnit> = best.into_values().map(|(_, unit)| unit).collect();
119    // Sort by line start for deterministic output
120    units.sort_by_key(|u| u.lines.start);
121
122    let regions_found = units.len() as u32;
123
124    Ok(SummaryOutput {
125        schema: "chronicle-summary/v1".to_string(),
126        query: QueryEcho {
127            file: query.file.clone(),
128            anchor: query.anchor.clone(),
129        },
130        units,
131        stats: SummaryStats {
132            regions_found,
133            commits_examined,
134        },
135    })
136}
137
138fn file_matches(a: &str, b: &str) -> bool {
139    fn norm(s: &str) -> &str {
140        s.strip_prefix("./").unwrap_or(s)
141    }
142    norm(a) == norm(b)
143}
144
145fn anchor_matches(region_anchor: &str, query_anchor: &str) -> bool {
146    if region_anchor == query_anchor {
147        return true;
148    }
149    let region_short = region_anchor.rsplit("::").next().unwrap_or(region_anchor);
150    let query_short = query_anchor.rsplit("::").next().unwrap_or(query_anchor);
151    region_short == query_anchor || region_anchor == query_short || region_short == query_short
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use crate::schema::annotation::*;
158
159    struct MockGitOps {
160        file_log: Vec<String>,
161        notes: std::collections::HashMap<String, String>,
162    }
163
164    impl GitOps for MockGitOps {
165        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
166            Ok(vec![])
167        }
168        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
169            Ok(self.notes.get(commit).cloned())
170        }
171        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
172            Ok(())
173        }
174        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
175            Ok(self.notes.contains_key(commit))
176        }
177        fn file_at_commit(
178            &self,
179            _path: &std::path::Path,
180            _commit: &str,
181        ) -> Result<String, GitError> {
182            Ok(String::new())
183        }
184        fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
185            Ok(crate::git::CommitInfo {
186                sha: "abc123".to_string(),
187                message: "test".to_string(),
188                author_name: "test".to_string(),
189                author_email: "test@test.com".to_string(),
190                timestamp: "2025-01-01T00:00:00Z".to_string(),
191                parent_shas: vec![],
192            })
193        }
194        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
195            Ok("abc123".to_string())
196        }
197        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
198            Ok(None)
199        }
200        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
201            Ok(())
202        }
203        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
204            Ok(self.file_log.clone())
205        }
206        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
207            Ok(vec![])
208        }
209    }
210
211    fn make_annotation(
212        commit: &str,
213        timestamp: &str,
214        regions: Vec<RegionAnnotation>,
215    ) -> Annotation {
216        Annotation {
217            schema: "chronicle/v1".to_string(),
218            commit: commit.to_string(),
219            timestamp: timestamp.to_string(),
220            task: None,
221            summary: "test".to_string(),
222            context_level: ContextLevel::Enhanced,
223            regions,
224            cross_cutting: vec![],
225            provenance: Provenance {
226                operation: ProvenanceOperation::Initial,
227                derived_from: vec![],
228                original_annotations_preserved: false,
229                synthesis_notes: None,
230            },
231        }
232    }
233
234    fn make_region(
235        file: &str,
236        anchor: &str,
237        unit_type: &str,
238        lines: LineRange,
239        intent: &str,
240        constraints: Vec<Constraint>,
241        risk_notes: Option<&str>,
242    ) -> RegionAnnotation {
243        RegionAnnotation {
244            file: file.to_string(),
245            ast_anchor: AstAnchor {
246                unit_type: unit_type.to_string(),
247                name: anchor.to_string(),
248                signature: None,
249            },
250            lines,
251            intent: intent.to_string(),
252            reasoning: Some("detailed reasoning".to_string()),
253            constraints,
254            semantic_dependencies: vec![],
255            related_annotations: vec![],
256            tags: vec!["tag1".to_string()],
257            risk_notes: risk_notes.map(|s| s.to_string()),
258            corrections: vec![],
259        }
260    }
261
262    #[test]
263    fn test_summary_single_file() {
264        let ann = make_annotation(
265            "commit1",
266            "2025-01-01T00:00:00Z",
267            vec![
268                make_region(
269                    "src/main.rs",
270                    "main",
271                    "fn",
272                    LineRange { start: 1, end: 10 },
273                    "entry point",
274                    vec![Constraint {
275                        text: "must not panic".to_string(),
276                        source: ConstraintSource::Author,
277                    }],
278                    Some("error handling is fragile"),
279                ),
280                make_region(
281                    "src/main.rs",
282                    "helper",
283                    "fn",
284                    LineRange { start: 12, end: 20 },
285                    "helper fn",
286                    vec![],
287                    None,
288                ),
289            ],
290        );
291
292        let mut notes = std::collections::HashMap::new();
293        notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
294
295        let git = MockGitOps {
296            file_log: vec!["commit1".to_string()],
297            notes,
298        };
299
300        let query = SummaryQuery {
301            file: "src/main.rs".to_string(),
302            anchor: None,
303        };
304
305        let result = build_summary(&git, &query).unwrap();
306        assert_eq!(result.units.len(), 2);
307
308        // Sorted by line start
309        assert_eq!(result.units[0].anchor.name, "main");
310        assert_eq!(result.units[0].intent, "entry point");
311        assert_eq!(result.units[0].constraints, vec!["must not panic"]);
312        assert_eq!(
313            result.units[0].risk_notes,
314            Some("error handling is fragile".to_string())
315        );
316
317        assert_eq!(result.units[1].anchor.name, "helper");
318        assert_eq!(result.units[1].intent, "helper fn");
319        assert!(result.units[1].constraints.is_empty());
320        assert!(result.units[1].risk_notes.is_none());
321    }
322
323    #[test]
324    fn test_summary_keeps_most_recent() {
325        let ann1 = make_annotation(
326            "commit1",
327            "2025-01-01T00:00:00Z",
328            vec![make_region(
329                "src/main.rs",
330                "main",
331                "fn",
332                LineRange { start: 1, end: 10 },
333                "old intent",
334                vec![],
335                None,
336            )],
337        );
338        let ann2 = make_annotation(
339            "commit2",
340            "2025-01-02T00:00:00Z",
341            vec![make_region(
342                "src/main.rs",
343                "main",
344                "fn",
345                LineRange { start: 1, end: 10 },
346                "new intent",
347                vec![],
348                None,
349            )],
350        );
351
352        let mut notes = std::collections::HashMap::new();
353        notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
354        notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
355
356        let git = MockGitOps {
357            // newest first (as git log returns)
358            file_log: vec!["commit2".to_string(), "commit1".to_string()],
359            notes,
360        };
361
362        let query = SummaryQuery {
363            file: "src/main.rs".to_string(),
364            anchor: None,
365        };
366
367        let result = build_summary(&git, &query).unwrap();
368        assert_eq!(result.units.len(), 1);
369        assert_eq!(result.units[0].intent, "new intent");
370    }
371
372    #[test]
373    fn test_summary_only_intent_constraints_risk() {
374        // Verify that reasoning and tags don't appear in the output
375        let ann = make_annotation(
376            "commit1",
377            "2025-01-01T00:00:00Z",
378            vec![make_region(
379                "src/main.rs",
380                "main",
381                "fn",
382                LineRange { start: 1, end: 10 },
383                "entry point",
384                vec![],
385                None,
386            )],
387        );
388
389        let mut notes = std::collections::HashMap::new();
390        notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
391
392        let git = MockGitOps {
393            file_log: vec!["commit1".to_string()],
394            notes,
395        };
396
397        let query = SummaryQuery {
398            file: "src/main.rs".to_string(),
399            anchor: None,
400        };
401
402        let result = build_summary(&git, &query).unwrap();
403        let json = serde_json::to_string(&result).unwrap();
404        // Should not contain "reasoning" or "tags" fields
405        assert!(!json.contains("\"reasoning\""));
406        assert!(!json.contains("\"tags\""));
407    }
408
409    #[test]
410    fn test_summary_empty_when_no_annotations() {
411        let git = MockGitOps {
412            file_log: vec!["commit1".to_string()],
413            notes: std::collections::HashMap::new(),
414        };
415
416        let query = SummaryQuery {
417            file: "src/main.rs".to_string(),
418            anchor: None,
419        };
420
421        let result = build_summary(&git, &query).unwrap();
422        assert!(result.units.is_empty());
423        assert_eq!(result.stats.regions_found, 0);
424    }
425
426    #[test]
427    fn test_summary_with_anchor_filter() {
428        let ann = make_annotation(
429            "commit1",
430            "2025-01-01T00:00:00Z",
431            vec![
432                make_region(
433                    "src/main.rs",
434                    "main",
435                    "fn",
436                    LineRange { start: 1, end: 10 },
437                    "entry point",
438                    vec![],
439                    None,
440                ),
441                make_region(
442                    "src/main.rs",
443                    "helper",
444                    "fn",
445                    LineRange { start: 12, end: 20 },
446                    "helper fn",
447                    vec![],
448                    None,
449                ),
450            ],
451        );
452
453        let mut notes = std::collections::HashMap::new();
454        notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
455
456        let git = MockGitOps {
457            file_log: vec!["commit1".to_string()],
458            notes,
459        };
460
461        let query = SummaryQuery {
462            file: "src/main.rs".to_string(),
463            anchor: Some("main".to_string()),
464        };
465
466        let result = build_summary(&git, &query).unwrap();
467        assert_eq!(result.units.len(), 1);
468        assert_eq!(result.units[0].anchor.name, "main");
469    }
470}