1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
4
5use super::matching::file_matches;
6
7#[derive(Debug, Clone)]
9pub struct DecisionsQuery {
10 pub file: Option<String>,
11}
12
13#[derive(Debug, Clone, serde::Serialize)]
15pub struct DecisionEntry {
16 pub what: String,
17 pub why: String,
18 pub stability: String,
19 pub revisit_when: Option<String>,
20 pub scope: Vec<String>,
21 pub commit: String,
22 pub timestamp: String,
23}
24
25#[derive(Debug, Clone, serde::Serialize)]
27pub struct RejectedAlternativeEntry {
28 pub approach: String,
29 pub reason: String,
30 pub commit: String,
31 pub timestamp: String,
32}
33
34#[derive(Debug, Clone, serde::Serialize)]
36pub struct DecisionsOutput {
37 pub schema: String,
38 pub decisions: Vec<DecisionEntry>,
39 pub rejected_alternatives: Vec<RejectedAlternativeEntry>,
40}
41
42pub fn query_decisions(
55 git: &dyn GitOps,
56 query: &DecisionsQuery,
57) -> Result<DecisionsOutput, GitError> {
58 let shas = match &query.file {
59 Some(file) => git.log_for_file(file)?,
60 None => git.list_annotated_commits(1000)?,
61 };
62
63 let mut best_decisions: std::collections::HashMap<String, DecisionEntry> =
65 std::collections::HashMap::new();
66 let mut best_rejected: std::collections::HashMap<String, RejectedAlternativeEntry> =
68 std::collections::HashMap::new();
69
70 for sha in &shas {
71 let note = match git.note_read(sha)? {
72 Some(n) => n,
73 None => continue,
74 };
75
76 let annotation = match schema::parse_annotation(¬e) {
77 Ok(a) => a,
78 Err(e) => {
79 tracing::debug!("skipping malformed annotation for {sha}: {e}");
80 continue;
81 }
82 };
83
84 for w in &annotation.wisdom {
85 if let Some(ref file) = query.file {
87 if let Some(ref wf) = w.file {
88 if !file_matches(wf, file) && !file.starts_with(wf.as_str()) {
89 continue;
90 }
91 }
92 }
94
95 match w.category {
96 v3::WisdomCategory::Insight => {
97 let (what, why) = if let Some((w_str, y_str)) = w.content.split_once(": ") {
99 (w_str.to_string(), y_str.to_string())
100 } else {
101 (w.content.clone(), String::new())
102 };
103
104 let scope = w.file.as_ref().map(|f| vec![f.clone()]).unwrap_or_default();
105
106 let key = w.content.clone();
107 best_decisions.entry(key).or_insert_with(|| DecisionEntry {
108 what,
109 why,
110 stability: "permanent".to_string(),
111 revisit_when: None,
112 scope,
113 commit: annotation.commit.clone(),
114 timestamp: annotation.timestamp.clone(),
115 });
116 }
117 v3::WisdomCategory::DeadEnd => {
118 let (approach, reason) =
120 if let Some((a_str, r_str)) = w.content.split_once(": ") {
121 (a_str.to_string(), r_str.to_string())
122 } else {
123 (w.content.clone(), String::new())
124 };
125
126 let key = w.content.clone();
127 best_rejected
128 .entry(key)
129 .or_insert_with(|| RejectedAlternativeEntry {
130 approach,
131 reason,
132 commit: annotation.commit.clone(),
133 timestamp: annotation.timestamp.clone(),
134 });
135 }
136 _ => {}
137 }
138 }
139 }
140
141 let mut decisions: Vec<DecisionEntry> = best_decisions.into_values().collect();
142 decisions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
143
144 let mut rejected_alternatives: Vec<RejectedAlternativeEntry> =
145 best_rejected.into_values().collect();
146 rejected_alternatives.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
147
148 Ok(DecisionsOutput {
149 schema: "chronicle-decisions/v1".to_string(),
150 decisions,
151 rejected_alternatives,
152 })
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::schema::common::{AstAnchor, LineRange};
159 use crate::schema::v1::{
160 ContextLevel, CrossCuttingConcern, CrossCuttingRegionRef, Provenance, ProvenanceOperation,
161 RegionAnnotation,
162 };
163
164 struct MockGitOps {
165 file_log: Vec<String>,
166 annotated_commits: Vec<String>,
167 notes: std::collections::HashMap<String, String>,
168 }
169
170 impl GitOps for MockGitOps {
171 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
172 Ok(vec![])
173 }
174 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
175 Ok(self.notes.get(commit).cloned())
176 }
177 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
178 Ok(())
179 }
180 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
181 Ok(self.notes.contains_key(commit))
182 }
183 fn file_at_commit(
184 &self,
185 _path: &std::path::Path,
186 _commit: &str,
187 ) -> Result<String, GitError> {
188 Ok(String::new())
189 }
190 fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
191 Ok(crate::git::CommitInfo {
192 sha: "abc123".to_string(),
193 message: "test".to_string(),
194 author_name: "test".to_string(),
195 author_email: "test@test.com".to_string(),
196 timestamp: "2025-01-01T00:00:00Z".to_string(),
197 parent_shas: vec![],
198 })
199 }
200 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
201 Ok("abc123".to_string())
202 }
203 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
204 Ok(None)
205 }
206 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
207 Ok(())
208 }
209 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
210 Ok(self.file_log.clone())
211 }
212 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
213 Ok(self.annotated_commits.clone())
214 }
215 }
216
217 fn make_v1_annotation_with_cross_cutting(
220 commit: &str,
221 timestamp: &str,
222 regions: Vec<RegionAnnotation>,
223 cross_cutting: Vec<CrossCuttingConcern>,
224 ) -> String {
225 let ann = crate::schema::v1::Annotation {
226 schema: "chronicle/v1".to_string(),
227 commit: commit.to_string(),
228 timestamp: timestamp.to_string(),
229 task: None,
230 summary: "test".to_string(),
231 context_level: ContextLevel::Enhanced,
232 regions,
233 cross_cutting,
234 provenance: Provenance {
235 operation: ProvenanceOperation::Initial,
236 derived_from: vec![],
237 original_annotations_preserved: false,
238 synthesis_notes: None,
239 },
240 };
241 serde_json::to_string(&ann).unwrap()
242 }
243
244 fn make_region(file: &str, anchor: &str) -> RegionAnnotation {
245 RegionAnnotation {
246 file: file.to_string(),
247 ast_anchor: AstAnchor {
248 unit_type: "function".to_string(),
249 name: anchor.to_string(),
250 signature: None,
251 },
252 lines: LineRange { start: 1, end: 10 },
253 intent: "test intent".to_string(),
254 reasoning: None,
255 constraints: vec![],
256 semantic_dependencies: vec![],
257 related_annotations: vec![],
258 tags: vec![],
259 risk_notes: None,
260 corrections: vec![],
261 }
262 }
263
264 #[test]
265 fn test_decisions_from_v1_cross_cutting() {
266 let note = make_v1_annotation_with_cross_cutting(
268 "commit1",
269 "2025-01-01T00:00:00Z",
270 vec![make_region("src/main.rs", "main")],
271 vec![CrossCuttingConcern {
272 description: "All paths validate input".to_string(),
273 regions: vec![CrossCuttingRegionRef {
274 file: "src/main.rs".to_string(),
275 anchor: "main".to_string(),
276 }],
277 tags: vec![],
278 }],
279 );
280
281 let mut notes = std::collections::HashMap::new();
282 notes.insert("commit1".to_string(), note);
283
284 let git = MockGitOps {
285 file_log: vec!["commit1".to_string()],
286 annotated_commits: vec![],
287 notes,
288 };
289
290 let query = DecisionsQuery {
291 file: Some("src/main.rs".to_string()),
292 };
293
294 let result = query_decisions(&git, &query).unwrap();
295 assert_eq!(result.schema, "chronicle-decisions/v1");
296 assert_eq!(result.decisions.len(), 1);
297 assert_eq!(result.decisions[0].what, "All paths validate input");
298 assert_eq!(result.decisions[0].stability, "permanent");
299 assert_eq!(result.decisions[0].commit, "commit1");
300 }
301
302 #[test]
303 fn test_decisions_dedup_keeps_newest() {
304 let note1 = make_v1_annotation_with_cross_cutting(
305 "commit1",
306 "2025-01-01T00:00:00Z",
307 vec![make_region("src/main.rs", "main")],
308 vec![CrossCuttingConcern {
309 description: "All paths validate input".to_string(),
310 regions: vec![CrossCuttingRegionRef {
311 file: "src/main.rs".to_string(),
312 anchor: "main".to_string(),
313 }],
314 tags: vec![],
315 }],
316 );
317 let note2 = make_v1_annotation_with_cross_cutting(
318 "commit2",
319 "2025-01-02T00:00:00Z",
320 vec![make_region("src/main.rs", "main")],
321 vec![CrossCuttingConcern {
322 description: "All paths validate input".to_string(),
323 regions: vec![CrossCuttingRegionRef {
324 file: "src/main.rs".to_string(),
325 anchor: "main".to_string(),
326 }],
327 tags: vec![],
328 }],
329 );
330
331 let mut notes = std::collections::HashMap::new();
332 notes.insert("commit1".to_string(), note1);
333 notes.insert("commit2".to_string(), note2);
334
335 let git = MockGitOps {
336 file_log: vec!["commit2".to_string(), "commit1".to_string()],
338 annotated_commits: vec![],
339 notes,
340 };
341
342 let query = DecisionsQuery {
343 file: Some("src/main.rs".to_string()),
344 };
345
346 let result = query_decisions(&git, &query).unwrap();
347 assert_eq!(result.decisions.len(), 1);
348 assert_eq!(result.decisions[0].commit, "commit2");
349 assert_eq!(result.decisions[0].timestamp, "2025-01-02T00:00:00Z");
350 }
351
352 #[test]
353 fn test_decisions_scope_filter() {
354 let note = make_v1_annotation_with_cross_cutting(
356 "commit1",
357 "2025-01-01T00:00:00Z",
358 vec![make_region("src/main.rs", "main")],
359 vec![CrossCuttingConcern {
360 description: "Config must be reloaded".to_string(),
361 regions: vec![CrossCuttingRegionRef {
362 file: "src/config.rs".to_string(),
363 anchor: "reload".to_string(),
364 }],
365 tags: vec![],
366 }],
367 );
368
369 let mut notes = std::collections::HashMap::new();
370 notes.insert("commit1".to_string(), note);
371
372 let git = MockGitOps {
373 file_log: vec!["commit1".to_string()],
374 annotated_commits: vec![],
375 notes,
376 };
377
378 let query = DecisionsQuery {
379 file: Some("src/main.rs".to_string()),
380 };
381
382 let result = query_decisions(&git, &query).unwrap();
383 assert_eq!(result.decisions.len(), 0);
386 }
387
388 #[test]
389 fn test_decisions_no_file_returns_all() {
390 let note = make_v1_annotation_with_cross_cutting(
391 "commit1",
392 "2025-01-01T00:00:00Z",
393 vec![make_region("src/main.rs", "main")],
394 vec![CrossCuttingConcern {
395 description: "All paths validate input".to_string(),
396 regions: vec![CrossCuttingRegionRef {
397 file: "src/main.rs".to_string(),
398 anchor: "main".to_string(),
399 }],
400 tags: vec![],
401 }],
402 );
403
404 let mut notes = std::collections::HashMap::new();
405 notes.insert("commit1".to_string(), note);
406
407 let git = MockGitOps {
408 file_log: vec![],
409 annotated_commits: vec!["commit1".to_string()],
410 notes,
411 };
412
413 let query = DecisionsQuery { file: None };
415
416 let result = query_decisions(&git, &query).unwrap();
417 assert_eq!(result.decisions.len(), 1);
418 assert_eq!(result.decisions[0].what, "All paths validate input");
419 }
420
421 #[test]
422 fn test_decisions_empty_when_no_annotations() {
423 let git = MockGitOps {
424 file_log: vec!["commit1".to_string()],
425 annotated_commits: vec![],
426 notes: std::collections::HashMap::new(),
427 };
428
429 let query = DecisionsQuery {
430 file: Some("src/main.rs".to_string()),
431 };
432
433 let result = query_decisions(&git, &query).unwrap();
434 assert!(result.decisions.is_empty());
435 assert!(result.rejected_alternatives.is_empty());
436 }
437
438 #[test]
439 fn test_decisions_with_native_v2_rejected_alternatives() {
440 use crate::schema::v2;
445
446 let v2_ann = v2::Annotation {
447 schema: "chronicle/v2".to_string(),
448 commit: "commit1".to_string(),
449 timestamp: "2025-01-01T00:00:00Z".to_string(),
450 narrative: v2::Narrative {
451 summary: "Chose HashMap over BTreeMap".to_string(),
452 motivation: None,
453 rejected_alternatives: vec![v2::RejectedAlternative {
454 approach: "BTreeMap for ordered iteration".to_string(),
455 reason: "Lookup performance is more important than ordering".to_string(),
456 }],
457 follow_up: None,
458 files_changed: vec!["src/store.rs".to_string()],
459 sentiments: vec![],
460 },
461 decisions: vec![v2::Decision {
462 what: "Use HashMap for the cache".to_string(),
463 why: "O(1) lookups are critical for the hot path".to_string(),
464 stability: v2::Stability::Provisional,
465 revisit_when: Some("If we need sorted keys".to_string()),
466 scope: vec!["src/store.rs".to_string()],
467 }],
468 markers: vec![],
469 effort: None,
470 provenance: v2::Provenance {
471 source: v2::ProvenanceSource::Live,
472 author: None,
473 derived_from: vec![],
474 notes: None,
475 },
476 };
477 let note = serde_json::to_string(&v2_ann).unwrap();
478
479 let mut notes = std::collections::HashMap::new();
480 notes.insert("commit1".to_string(), note);
481
482 let git = MockGitOps {
483 file_log: vec!["commit1".to_string()],
484 annotated_commits: vec![],
485 notes,
486 };
487
488 let query = DecisionsQuery {
489 file: Some("src/store.rs".to_string()),
490 };
491
492 let result = query_decisions(&git, &query).unwrap();
493
494 assert_eq!(result.decisions.len(), 1);
496 assert_eq!(result.decisions[0].what, "Use HashMap for the cache");
497 assert_eq!(
498 result.decisions[0].why,
499 "O(1) lookups are critical for the hot path"
500 );
501
502 assert_eq!(result.rejected_alternatives.len(), 1);
504 assert_eq!(
505 result.rejected_alternatives[0].approach,
506 "BTreeMap for ordered iteration"
507 );
508 assert_eq!(
509 result.rejected_alternatives[0].reason,
510 "Lookup performance is more important than ordering"
511 );
512 }
513
514 #[test]
515 fn test_decisions_output_serializable() {
516 let output = DecisionsOutput {
517 schema: "chronicle-decisions/v1".to_string(),
518 decisions: vec![DecisionEntry {
519 what: "Use HashMap".to_string(),
520 why: "Performance".to_string(),
521 stability: "provisional".to_string(),
522 revisit_when: Some("If ordering needed".to_string()),
523 scope: vec!["src/store.rs".to_string()],
524 commit: "abc123".to_string(),
525 timestamp: "2025-01-01T00:00:00Z".to_string(),
526 }],
527 rejected_alternatives: vec![RejectedAlternativeEntry {
528 approach: "BTreeMap".to_string(),
529 reason: "Slower lookups".to_string(),
530 commit: "abc123".to_string(),
531 timestamp: "2025-01-01T00:00:00Z".to_string(),
532 }],
533 };
534
535 let json = serde_json::to_string(&output).unwrap();
536 assert!(json.contains("chronicle-decisions/v1"));
537 assert!(json.contains("Use HashMap"));
538 assert!(json.contains("BTreeMap"));
539 }
540}