Skip to main content

chronicle/read/
decisions.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
4
5use super::matching::file_matches;
6
7/// Query parameters: "What was decided and what was tried?"
8#[derive(Debug, Clone)]
9pub struct DecisionsQuery {
10    pub file: Option<String>,
11}
12
13/// A decision entry extracted from insight wisdom entries.
14#[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/// A rejected alternative extracted from dead_end wisdom entries.
26#[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/// Output of a decisions query.
35#[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
42/// Collect decisions and rejected alternatives from annotations.
43///
44/// In v3, decisions come from `insight` wisdom entries whose content
45/// matches the "{what}: {why}" pattern (from v2 decision migration),
46/// and rejected alternatives come from `dead_end` wisdom entries whose
47/// content matches the "{approach}: {reason}" pattern.
48///
49/// 1. Determine which commits to examine
50/// 2. For each commit, parse annotation via `parse_annotation`
51/// 3. Collect insight entries as decisions, dead_end entries as rejected alternatives
52/// 4. When a file is given, filter by wisdom entry file scope
53/// 5. Deduplicate by content, keeping the most recent
54pub 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    // Key: content -> DecisionEntry (first match wins, newest first)
64    let mut best_decisions: std::collections::HashMap<String, DecisionEntry> =
65        std::collections::HashMap::new();
66    // Key: content -> RejectedAlternativeEntry
67    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(&note) {
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 filtering by file, check scope
86            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                // Wisdom with no file scope is repo-wide, include it
93            }
94
95            match w.category {
96                v3::WisdomCategory::Insight => {
97                    // Parse "{what}: {why}" format from v2 decision migration
98                    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                    // Parse "{approach}: {reason}" format from v2 rejected_alternatives migration
119                    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    /// Build a v1 annotation JSON string. parse_annotation() will migrate
218    /// it to v3, so cross_cutting concerns become insight wisdom entries.
219    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        // v1 cross-cutting concerns migrate to insight wisdom entries
267        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            // newest first
337            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        // Decision scoped to src/config.rs should not appear when querying src/main.rs
355        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        // The migrated decision's scope is "src/config.rs:reload", which
384        // doesn't match "src/main.rs", so it should be filtered out.
385        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        // No file filter: uses list_annotated_commits
414        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        // Build a native v2 annotation. parse_annotation() migrates it to v3.
441        // parse_annotation() will migrate it to v3, where:
442        //   - decisions become insight wisdom: "Use HashMap for the cache: O(1) lookups..."
443        //   - rejected_alternatives become dead_end wisdom: "BTreeMap for ordered iteration: Lookup..."
444        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        // v2 decision migrates to insight wisdom with "{what}: {why}" format
495        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        // v2 rejected_alternative migrates to dead_end wisdom with "{approach}: {reason}" format
503        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}