1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
4
5#[derive(Debug, Clone)]
7pub struct HistoryQuery {
8 pub file: String,
9 pub anchor: Option<String>,
10 pub limit: u32,
11}
12
13#[derive(Debug, Clone, serde::Serialize)]
15pub struct TimelineEntry {
16 pub commit: String,
17 pub timestamp: String,
18 pub commit_message: String,
19 pub context_level: String,
20 pub provenance: String,
21 pub intent: String,
22 pub original_schema: String,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub reasoning: Option<String>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub constraints: Vec<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub risk_notes: Option<String>,
30}
31
32#[derive(Debug, Clone, serde::Serialize)]
34pub struct HistoryStats {
35 pub commits_in_log: u32,
36 pub annotations_found: u32,
37}
38
39#[derive(Debug, Clone, serde::Serialize)]
41pub struct HistoryOutput {
42 pub schema: String,
43 pub query: QueryEcho,
44 pub timeline: Vec<TimelineEntry>,
45 pub stats: HistoryStats,
46}
47
48#[derive(Debug, Clone, serde::Serialize)]
50pub struct QueryEcho {
51 pub file: String,
52 pub anchor: Option<String>,
53}
54
55pub fn build_timeline(git: &dyn GitOps, query: &HistoryQuery) -> Result<HistoryOutput, GitError> {
62 let shas = git.log_for_file(&query.file)?;
63 let commits_in_log = shas.len() as u32;
64
65 let mut entries: Vec<TimelineEntry> = Vec::new();
66
67 for sha in &shas {
68 let note = match git.note_read(sha)? {
69 Some(n) => n,
70 None => continue,
71 };
72
73 let annotation = match schema::parse_annotation(¬e) {
74 Ok(a) => a,
75 Err(e) => {
76 tracing::debug!("skipping malformed annotation for {sha}: {e}");
77 continue;
78 }
79 };
80
81 let commit_msg = git
82 .commit_info(sha)
83 .map(|ci| ci.message.clone())
84 .unwrap_or_default();
85
86 let constraints: Vec<String> = annotation
91 .wisdom
92 .iter()
93 .filter(|w| w.category == v3::WisdomCategory::Gotcha)
94 .filter(|w| w.file.as_ref().is_none_or(|f| file_matches(f, &query.file)))
95 .map(|w| w.content.clone())
96 .collect();
97
98 let risk_notes: Option<String> = None;
100
101 let context_level = annotation.provenance.source.to_string();
102 let original_schema = schema::peek_version(¬e).unwrap_or_else(|| "unknown".to_string());
103
104 entries.push(TimelineEntry {
105 commit: sha.clone(),
106 timestamp: annotation.timestamp.clone(),
107 commit_message: commit_msg,
108 context_level: context_level.clone(),
109 provenance: context_level,
110 intent: annotation.summary.clone(),
111 original_schema,
112 reasoning: None,
113 constraints,
114 risk_notes,
115 });
116 }
117
118 entries.reverse();
120
121 let annotations_found = entries.len() as u32;
122
123 if entries.len() > query.limit as usize {
125 let start = entries.len() - query.limit as usize;
126 entries = entries.split_off(start);
127 }
128
129 Ok(HistoryOutput {
130 schema: "chronicle-history/v1".to_string(),
131 query: QueryEcho {
132 file: query.file.clone(),
133 anchor: query.anchor.clone(),
134 },
135 timeline: entries,
136 stats: HistoryStats {
137 commits_in_log,
138 annotations_found,
139 },
140 })
141}
142
143use super::matching::file_matches;
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148 use crate::schema::common::AstAnchor;
149 use crate::schema::v2;
150
151 struct MockGitOps {
152 file_log: Vec<String>,
153 notes: std::collections::HashMap<String, String>,
154 commit_messages: std::collections::HashMap<String, String>,
155 }
156
157 impl GitOps for MockGitOps {
158 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
159 Ok(vec![])
160 }
161 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
162 Ok(self.notes.get(commit).cloned())
163 }
164 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
165 Ok(())
166 }
167 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
168 Ok(self.notes.contains_key(commit))
169 }
170 fn file_at_commit(
171 &self,
172 _path: &std::path::Path,
173 _commit: &str,
174 ) -> Result<String, GitError> {
175 Ok(String::new())
176 }
177 fn commit_info(&self, commit: &str) -> Result<crate::git::CommitInfo, GitError> {
178 Ok(crate::git::CommitInfo {
179 sha: commit.to_string(),
180 message: self
181 .commit_messages
182 .get(commit)
183 .cloned()
184 .unwrap_or_default(),
185 author_name: "test".to_string(),
186 author_email: "test@test.com".to_string(),
187 timestamp: "2025-01-01T00:00:00Z".to_string(),
188 parent_shas: vec![],
189 })
190 }
191 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
192 Ok("abc123".to_string())
193 }
194 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
195 Ok(None)
196 }
197 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
198 Ok(())
199 }
200 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
201 Ok(self.file_log.clone())
202 }
203 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
204 Ok(vec![])
205 }
206 }
207
208 fn make_v2_annotation_with_intent(
209 commit: &str,
210 timestamp: &str,
211 summary: &str,
212 files_changed: Vec<&str>,
213 markers: Vec<v2::CodeMarker>,
214 ) -> String {
215 let ann = v2::Annotation {
216 schema: "chronicle/v2".to_string(),
217 commit: commit.to_string(),
218 timestamp: timestamp.to_string(),
219 narrative: v2::Narrative {
220 summary: summary.to_string(),
221 motivation: None,
222 rejected_alternatives: vec![],
223 follow_up: None,
224 files_changed: files_changed.into_iter().map(|s| s.to_string()).collect(),
225 sentiments: vec![],
226 },
227 decisions: vec![],
228 markers,
229 effort: None,
230 provenance: v2::Provenance {
231 source: v2::ProvenanceSource::Live,
232 author: None,
233 derived_from: vec![],
234 notes: None,
235 },
236 };
237 serde_json::to_string(&ann).unwrap()
238 }
239
240 fn make_contract_marker(file: &str, anchor: &str, description: &str) -> v2::CodeMarker {
241 v2::CodeMarker {
242 file: file.to_string(),
243 anchor: Some(AstAnchor {
244 unit_type: "fn".to_string(),
245 name: anchor.to_string(),
246 signature: None,
247 }),
248 lines: None,
249 kind: v2::MarkerKind::Contract {
250 description: description.to_string(),
251 source: v2::ContractSource::Author,
252 },
253 }
254 }
255
256 #[test]
257 fn test_single_commit_history() {
258 let note = make_v2_annotation_with_intent(
259 "commit1",
260 "2025-01-01T00:00:00Z",
261 "entry point",
262 vec!["src/main.rs"],
263 vec![make_contract_marker(
264 "src/main.rs",
265 "main",
266 "must not panic",
267 )],
268 );
269
270 let mut notes = std::collections::HashMap::new();
271 notes.insert("commit1".to_string(), note);
272 let mut msgs = std::collections::HashMap::new();
273 msgs.insert("commit1".to_string(), "initial commit".to_string());
274
275 let git = MockGitOps {
276 file_log: vec!["commit1".to_string()],
277 notes,
278 commit_messages: msgs,
279 };
280
281 let query = HistoryQuery {
282 file: "src/main.rs".to_string(),
283 anchor: Some("main".to_string()),
284 limit: 10,
285 };
286
287 let result = build_timeline(&git, &query).unwrap();
288 assert_eq!(result.timeline.len(), 1);
289 assert_eq!(result.timeline[0].intent, "entry point");
290 assert_eq!(result.timeline[0].commit_message, "initial commit");
291 }
292
293 #[test]
294 fn test_multi_commit_chronological_order() {
295 let note1 = make_v2_annotation_with_intent(
296 "commit1",
297 "2025-01-01T00:00:00Z",
298 "v1 entry",
299 vec!["src/main.rs"],
300 vec![],
301 );
302 let note2 = make_v2_annotation_with_intent(
303 "commit2",
304 "2025-01-02T00:00:00Z",
305 "v2 entry",
306 vec!["src/main.rs"],
307 vec![],
308 );
309 let note3 = make_v2_annotation_with_intent(
310 "commit3",
311 "2025-01-03T00:00:00Z",
312 "v3 entry",
313 vec!["src/main.rs"],
314 vec![],
315 );
316
317 let mut notes = std::collections::HashMap::new();
318 notes.insert("commit1".to_string(), note1);
319 notes.insert("commit2".to_string(), note2);
320 notes.insert("commit3".to_string(), note3);
321
322 let git = MockGitOps {
323 file_log: vec![
325 "commit3".to_string(),
326 "commit2".to_string(),
327 "commit1".to_string(),
328 ],
329 notes,
330 commit_messages: std::collections::HashMap::new(),
331 };
332
333 let query = HistoryQuery {
334 file: "src/main.rs".to_string(),
335 anchor: None,
336 limit: 10,
337 };
338
339 let result = build_timeline(&git, &query).unwrap();
340 assert_eq!(result.timeline.len(), 3);
341 assert_eq!(result.timeline[0].intent, "v1 entry");
343 assert_eq!(result.timeline[1].intent, "v2 entry");
344 assert_eq!(result.timeline[2].intent, "v3 entry");
345 }
346
347 #[test]
348 fn test_limit_respected() {
349 let note1 = make_v2_annotation_with_intent(
350 "commit1",
351 "2025-01-01T00:00:00Z",
352 "v1",
353 vec!["src/main.rs"],
354 vec![],
355 );
356 let note2 = make_v2_annotation_with_intent(
357 "commit2",
358 "2025-01-02T00:00:00Z",
359 "v2",
360 vec!["src/main.rs"],
361 vec![],
362 );
363 let note3 = make_v2_annotation_with_intent(
364 "commit3",
365 "2025-01-03T00:00:00Z",
366 "v3",
367 vec!["src/main.rs"],
368 vec![],
369 );
370
371 let mut notes = std::collections::HashMap::new();
372 notes.insert("commit1".to_string(), note1);
373 notes.insert("commit2".to_string(), note2);
374 notes.insert("commit3".to_string(), note3);
375
376 let git = MockGitOps {
377 file_log: vec![
378 "commit3".to_string(),
379 "commit2".to_string(),
380 "commit1".to_string(),
381 ],
382 notes,
383 commit_messages: std::collections::HashMap::new(),
384 };
385
386 let query = HistoryQuery {
387 file: "src/main.rs".to_string(),
388 anchor: None,
389 limit: 2,
390 };
391
392 let result = build_timeline(&git, &query).unwrap();
393 assert_eq!(result.timeline.len(), 2);
395 assert_eq!(result.timeline[0].intent, "v2");
396 assert_eq!(result.timeline[1].intent, "v3");
397 assert_eq!(result.stats.annotations_found, 3);
398 }
399
400 #[test]
401 fn test_commit_without_annotation_skipped() {
402 let note = make_v2_annotation_with_intent(
403 "commit1",
404 "2025-01-01T00:00:00Z",
405 "v1",
406 vec!["src/main.rs"],
407 vec![],
408 );
409
410 let mut notes = std::collections::HashMap::new();
411 notes.insert("commit1".to_string(), note);
412 let git = MockGitOps {
415 file_log: vec!["commit2".to_string(), "commit1".to_string()],
416 notes,
417 commit_messages: std::collections::HashMap::new(),
418 };
419
420 let query = HistoryQuery {
421 file: "src/main.rs".to_string(),
422 anchor: None,
423 limit: 10,
424 };
425
426 let result = build_timeline(&git, &query).unwrap();
427 assert_eq!(result.timeline.len(), 1);
428 assert_eq!(result.stats.commits_in_log, 2);
429 }
430}