1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::annotation::{Annotation, LineRange};
4
5#[derive(Debug, Clone)]
7pub struct SummaryQuery {
8 pub file: String,
9 pub anchor: Option<String>,
10}
11
12#[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#[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#[derive(Debug, Clone, serde::Serialize)]
37pub struct SummaryStats {
38 pub regions_found: u32,
39 pub commits_examined: u32,
40}
41
42#[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#[derive(Debug, Clone, serde::Serialize)]
53pub struct QueryEcho {
54 pub file: String,
55 pub anchor: Option<String>,
56}
57
58pub 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 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(¬e) {
80 Ok(a) => a,
81 Err(_) => continue,
82 };
83
84 for region in &annotation.regions {
85 if !file_matches(®ion.file, &query.file) {
86 continue;
87 }
88 if let Some(ref anchor_name) = query.anchor {
89 if !anchor_matches(®ion.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 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 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 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 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 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 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}