Skip to main content

chronicle/read/
decisions.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
4
5/// Query parameters: "What was decided and what was tried?"
6#[derive(Debug, Clone)]
7pub struct DecisionsQuery {
8    pub file: Option<String>,
9}
10
11/// A decision entry extracted from a v2 `Decision`.
12#[derive(Debug, Clone, serde::Serialize)]
13pub struct DecisionEntry {
14    pub what: String,
15    pub why: String,
16    pub stability: String,
17    pub revisit_when: Option<String>,
18    pub scope: Vec<String>,
19    pub commit: String,
20    pub timestamp: String,
21}
22
23/// A rejected alternative extracted from a v2 `Narrative.rejected_alternatives`.
24#[derive(Debug, Clone, serde::Serialize)]
25pub struct RejectedAlternativeEntry {
26    pub approach: String,
27    pub reason: String,
28    pub commit: String,
29    pub timestamp: String,
30}
31
32/// Output of a decisions query.
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct DecisionsOutput {
35    pub schema: String,
36    pub decisions: Vec<DecisionEntry>,
37    pub rejected_alternatives: Vec<RejectedAlternativeEntry>,
38}
39
40/// Collect decisions and rejected alternatives from annotations.
41///
42/// 1. Determine which commits to examine:
43///    - If a file is specified, use `log_for_file` to get commits touching that file
44///    - Otherwise, use `list_annotated_commits` to scan all annotated commits
45/// 2. For each commit, parse annotation via `parse_annotation` (handles v1 migration)
46/// 3. Collect decisions and rejected alternatives
47/// 4. When a file is given, filter decisions to those whose scope includes the file
48/// 5. Deduplicate decisions by `what` field, keeping the most recent
49pub fn query_decisions(
50    git: &dyn GitOps,
51    query: &DecisionsQuery,
52) -> Result<DecisionsOutput, GitError> {
53    let shas = match &query.file {
54        Some(file) => git.log_for_file(file)?,
55        None => git.list_annotated_commits(1000)?,
56    };
57
58    // Key: decision.what -> DecisionEntry (first match wins, newest first)
59    let mut best_decisions: std::collections::HashMap<String, DecisionEntry> =
60        std::collections::HashMap::new();
61    // Key: (approach, reason) -> RejectedAlternativeEntry
62    let mut best_rejected: std::collections::HashMap<String, RejectedAlternativeEntry> =
63        std::collections::HashMap::new();
64
65    for sha in &shas {
66        let note = match git.note_read(sha)? {
67            Some(n) => n,
68            None => continue,
69        };
70
71        let annotation: v2::Annotation = match schema::parse_annotation(&note) {
72            Ok(a) => a,
73            Err(e) => {
74                tracing::debug!("skipping malformed annotation for {sha}: {e}");
75                continue;
76            }
77        };
78
79        // Collect decisions, optionally filtered by scope
80        for decision in &annotation.decisions {
81            if let Some(ref file) = query.file {
82                if !decision_scope_matches(decision, file) {
83                    continue;
84                }
85            }
86
87            let stability_str = stability_to_string(&decision.stability);
88
89            let key = decision.what.clone();
90            best_decisions.entry(key).or_insert_with(|| DecisionEntry {
91                what: decision.what.clone(),
92                why: decision.why.clone(),
93                stability: stability_str,
94                revisit_when: decision.revisit_when.clone(),
95                scope: decision.scope.clone(),
96                commit: annotation.commit.clone(),
97                timestamp: annotation.timestamp.clone(),
98            });
99        }
100
101        // Collect rejected alternatives from narrative
102        for rejected in &annotation.narrative.rejected_alternatives {
103            // When filtering by file, only include rejected alternatives from
104            // commits that touched the file (which is already the case since
105            // we used log_for_file). For the no-file case, include all.
106            let key = format!("{}:{}", rejected.approach, rejected.reason);
107            best_rejected
108                .entry(key)
109                .or_insert_with(|| RejectedAlternativeEntry {
110                    approach: rejected.approach.clone(),
111                    reason: rejected.reason.clone(),
112                    commit: annotation.commit.clone(),
113                    timestamp: annotation.timestamp.clone(),
114                });
115        }
116    }
117
118    let mut decisions: Vec<DecisionEntry> = best_decisions.into_values().collect();
119    decisions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
120
121    let mut rejected_alternatives: Vec<RejectedAlternativeEntry> =
122        best_rejected.into_values().collect();
123    rejected_alternatives.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
124
125    Ok(DecisionsOutput {
126        schema: "chronicle-decisions/v1".to_string(),
127        decisions,
128        rejected_alternatives,
129    })
130}
131
132/// Check if a decision's scope matches the queried file.
133///
134/// A decision matches if:
135/// - Its scope is empty (applies globally)
136/// - Any scope entry starts with the file path or contains the file name
137fn decision_scope_matches(decision: &v2::Decision, file: &str) -> bool {
138    if decision.scope.is_empty() {
139        return true;
140    }
141    let norm_file = file.strip_prefix("./").unwrap_or(file);
142    decision.scope.iter().any(|s| {
143        let norm_scope = s.strip_prefix("./").unwrap_or(s);
144        // Scope entry could be "src/foo.rs:bar_fn" (file:anchor) or just "src/foo.rs"
145        let scope_file = norm_scope.split(':').next().unwrap_or(norm_scope);
146        scope_file == norm_file || norm_file.starts_with(scope_file)
147    })
148}
149
150fn stability_to_string(stability: &v2::Stability) -> String {
151    match stability {
152        v2::Stability::Permanent => "permanent".to_string(),
153        v2::Stability::Provisional => "provisional".to_string(),
154        v2::Stability::Experimental => "experimental".to_string(),
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::schema::common::{AstAnchor, LineRange};
162    use crate::schema::v1::{
163        ContextLevel, CrossCuttingConcern, CrossCuttingRegionRef, Provenance, ProvenanceOperation,
164        RegionAnnotation,
165    };
166
167    struct MockGitOps {
168        file_log: Vec<String>,
169        annotated_commits: Vec<String>,
170        notes: std::collections::HashMap<String, String>,
171    }
172
173    impl GitOps for MockGitOps {
174        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
175            Ok(vec![])
176        }
177        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
178            Ok(self.notes.get(commit).cloned())
179        }
180        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
181            Ok(())
182        }
183        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
184            Ok(self.notes.contains_key(commit))
185        }
186        fn file_at_commit(
187            &self,
188            _path: &std::path::Path,
189            _commit: &str,
190        ) -> Result<String, GitError> {
191            Ok(String::new())
192        }
193        fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
194            Ok(crate::git::CommitInfo {
195                sha: "abc123".to_string(),
196                message: "test".to_string(),
197                author_name: "test".to_string(),
198                author_email: "test@test.com".to_string(),
199                timestamp: "2025-01-01T00:00:00Z".to_string(),
200                parent_shas: vec![],
201            })
202        }
203        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
204            Ok("abc123".to_string())
205        }
206        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
207            Ok(None)
208        }
209        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
210            Ok(())
211        }
212        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
213            Ok(self.file_log.clone())
214        }
215        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
216            Ok(self.annotated_commits.clone())
217        }
218    }
219
220    /// Build a v1 annotation JSON string. parse_annotation() will migrate
221    /// it to v2, so cross_cutting concerns become v2 decisions.
222    fn make_v1_annotation_with_cross_cutting(
223        commit: &str,
224        timestamp: &str,
225        regions: Vec<RegionAnnotation>,
226        cross_cutting: Vec<CrossCuttingConcern>,
227    ) -> String {
228        let ann = crate::schema::v1::Annotation {
229            schema: "chronicle/v1".to_string(),
230            commit: commit.to_string(),
231            timestamp: timestamp.to_string(),
232            task: None,
233            summary: "test".to_string(),
234            context_level: ContextLevel::Enhanced,
235            regions,
236            cross_cutting,
237            provenance: Provenance {
238                operation: ProvenanceOperation::Initial,
239                derived_from: vec![],
240                original_annotations_preserved: false,
241                synthesis_notes: None,
242            },
243        };
244        serde_json::to_string(&ann).unwrap()
245    }
246
247    fn make_region(file: &str, anchor: &str) -> RegionAnnotation {
248        RegionAnnotation {
249            file: file.to_string(),
250            ast_anchor: AstAnchor {
251                unit_type: "function".to_string(),
252                name: anchor.to_string(),
253                signature: None,
254            },
255            lines: LineRange { start: 1, end: 10 },
256            intent: "test intent".to_string(),
257            reasoning: None,
258            constraints: vec![],
259            semantic_dependencies: vec![],
260            related_annotations: vec![],
261            tags: vec![],
262            risk_notes: None,
263            corrections: vec![],
264        }
265    }
266
267    #[test]
268    fn test_decisions_from_v1_cross_cutting() {
269        // v1 cross-cutting concerns migrate to v2 decisions
270        let note = make_v1_annotation_with_cross_cutting(
271            "commit1",
272            "2025-01-01T00:00:00Z",
273            vec![make_region("src/main.rs", "main")],
274            vec![CrossCuttingConcern {
275                description: "All paths validate input".to_string(),
276                regions: vec![CrossCuttingRegionRef {
277                    file: "src/main.rs".to_string(),
278                    anchor: "main".to_string(),
279                }],
280                tags: vec![],
281            }],
282        );
283
284        let mut notes = std::collections::HashMap::new();
285        notes.insert("commit1".to_string(), note);
286
287        let git = MockGitOps {
288            file_log: vec!["commit1".to_string()],
289            annotated_commits: vec![],
290            notes,
291        };
292
293        let query = DecisionsQuery {
294            file: Some("src/main.rs".to_string()),
295        };
296
297        let result = query_decisions(&git, &query).unwrap();
298        assert_eq!(result.schema, "chronicle-decisions/v1");
299        assert_eq!(result.decisions.len(), 1);
300        assert_eq!(result.decisions[0].what, "All paths validate input");
301        assert_eq!(result.decisions[0].stability, "permanent");
302        assert_eq!(result.decisions[0].commit, "commit1");
303    }
304
305    #[test]
306    fn test_decisions_dedup_keeps_newest() {
307        let note1 = make_v1_annotation_with_cross_cutting(
308            "commit1",
309            "2025-01-01T00:00:00Z",
310            vec![make_region("src/main.rs", "main")],
311            vec![CrossCuttingConcern {
312                description: "All paths validate input".to_string(),
313                regions: vec![CrossCuttingRegionRef {
314                    file: "src/main.rs".to_string(),
315                    anchor: "main".to_string(),
316                }],
317                tags: vec![],
318            }],
319        );
320        let note2 = make_v1_annotation_with_cross_cutting(
321            "commit2",
322            "2025-01-02T00:00:00Z",
323            vec![make_region("src/main.rs", "main")],
324            vec![CrossCuttingConcern {
325                description: "All paths validate input".to_string(),
326                regions: vec![CrossCuttingRegionRef {
327                    file: "src/main.rs".to_string(),
328                    anchor: "main".to_string(),
329                }],
330                tags: vec![],
331            }],
332        );
333
334        let mut notes = std::collections::HashMap::new();
335        notes.insert("commit1".to_string(), note1);
336        notes.insert("commit2".to_string(), note2);
337
338        let git = MockGitOps {
339            // newest first
340            file_log: vec!["commit2".to_string(), "commit1".to_string()],
341            annotated_commits: vec![],
342            notes,
343        };
344
345        let query = DecisionsQuery {
346            file: Some("src/main.rs".to_string()),
347        };
348
349        let result = query_decisions(&git, &query).unwrap();
350        assert_eq!(result.decisions.len(), 1);
351        assert_eq!(result.decisions[0].commit, "commit2");
352        assert_eq!(result.decisions[0].timestamp, "2025-01-02T00:00:00Z");
353    }
354
355    #[test]
356    fn test_decisions_scope_filter() {
357        // Decision scoped to src/config.rs should not appear when querying src/main.rs
358        let note = make_v1_annotation_with_cross_cutting(
359            "commit1",
360            "2025-01-01T00:00:00Z",
361            vec![make_region("src/main.rs", "main")],
362            vec![CrossCuttingConcern {
363                description: "Config must be reloaded".to_string(),
364                regions: vec![CrossCuttingRegionRef {
365                    file: "src/config.rs".to_string(),
366                    anchor: "reload".to_string(),
367                }],
368                tags: vec![],
369            }],
370        );
371
372        let mut notes = std::collections::HashMap::new();
373        notes.insert("commit1".to_string(), note);
374
375        let git = MockGitOps {
376            file_log: vec!["commit1".to_string()],
377            annotated_commits: vec![],
378            notes,
379        };
380
381        let query = DecisionsQuery {
382            file: Some("src/main.rs".to_string()),
383        };
384
385        let result = query_decisions(&git, &query).unwrap();
386        // The migrated decision's scope is "src/config.rs:reload", which
387        // doesn't match "src/main.rs", so it should be filtered out.
388        assert_eq!(result.decisions.len(), 0);
389    }
390
391    #[test]
392    fn test_decisions_no_file_returns_all() {
393        let note = make_v1_annotation_with_cross_cutting(
394            "commit1",
395            "2025-01-01T00:00:00Z",
396            vec![make_region("src/main.rs", "main")],
397            vec![CrossCuttingConcern {
398                description: "All paths validate input".to_string(),
399                regions: vec![CrossCuttingRegionRef {
400                    file: "src/main.rs".to_string(),
401                    anchor: "main".to_string(),
402                }],
403                tags: vec![],
404            }],
405        );
406
407        let mut notes = std::collections::HashMap::new();
408        notes.insert("commit1".to_string(), note);
409
410        let git = MockGitOps {
411            file_log: vec![],
412            annotated_commits: vec!["commit1".to_string()],
413            notes,
414        };
415
416        // No file filter: uses list_annotated_commits
417        let query = DecisionsQuery { file: None };
418
419        let result = query_decisions(&git, &query).unwrap();
420        assert_eq!(result.decisions.len(), 1);
421        assert_eq!(result.decisions[0].what, "All paths validate input");
422    }
423
424    #[test]
425    fn test_decisions_empty_when_no_annotations() {
426        let git = MockGitOps {
427            file_log: vec!["commit1".to_string()],
428            annotated_commits: vec![],
429            notes: std::collections::HashMap::new(),
430        };
431
432        let query = DecisionsQuery {
433            file: Some("src/main.rs".to_string()),
434        };
435
436        let result = query_decisions(&git, &query).unwrap();
437        assert!(result.decisions.is_empty());
438        assert!(result.rejected_alternatives.is_empty());
439    }
440
441    #[test]
442    fn test_decisions_with_native_v2_rejected_alternatives() {
443        // Build a native v2 annotation with rejected_alternatives in the narrative
444        let v2_ann = v2::Annotation {
445            schema: "chronicle/v2".to_string(),
446            commit: "commit1".to_string(),
447            timestamp: "2025-01-01T00:00:00Z".to_string(),
448            narrative: v2::Narrative {
449                summary: "Chose HashMap over BTreeMap".to_string(),
450                motivation: None,
451                rejected_alternatives: vec![v2::RejectedAlternative {
452                    approach: "BTreeMap for ordered iteration".to_string(),
453                    reason: "Lookup performance is more important than ordering".to_string(),
454                }],
455                follow_up: None,
456                files_changed: vec!["src/store.rs".to_string()],
457            },
458            decisions: vec![v2::Decision {
459                what: "Use HashMap for the cache".to_string(),
460                why: "O(1) lookups are critical for the hot path".to_string(),
461                stability: v2::Stability::Provisional,
462                revisit_when: Some("If we need sorted keys".to_string()),
463                scope: vec!["src/store.rs".to_string()],
464            }],
465            markers: vec![],
466            effort: None,
467            provenance: v2::Provenance {
468                source: v2::ProvenanceSource::Live,
469                author: None,
470                derived_from: vec![],
471                notes: None,
472            },
473        };
474        let note = serde_json::to_string(&v2_ann).unwrap();
475
476        let mut notes = std::collections::HashMap::new();
477        notes.insert("commit1".to_string(), note);
478
479        let git = MockGitOps {
480            file_log: vec!["commit1".to_string()],
481            annotated_commits: vec![],
482            notes,
483        };
484
485        let query = DecisionsQuery {
486            file: Some("src/store.rs".to_string()),
487        };
488
489        let result = query_decisions(&git, &query).unwrap();
490
491        assert_eq!(result.decisions.len(), 1);
492        assert_eq!(result.decisions[0].what, "Use HashMap for the cache");
493        assert_eq!(result.decisions[0].stability, "provisional");
494        assert_eq!(
495            result.decisions[0].revisit_when.as_deref(),
496            Some("If we need sorted keys")
497        );
498
499        assert_eq!(result.rejected_alternatives.len(), 1);
500        assert_eq!(
501            result.rejected_alternatives[0].approach,
502            "BTreeMap for ordered iteration"
503        );
504        assert_eq!(
505            result.rejected_alternatives[0].reason,
506            "Lookup performance is more important than ordering"
507        );
508    }
509
510    #[test]
511    fn test_decisions_output_serializable() {
512        let output = DecisionsOutput {
513            schema: "chronicle-decisions/v1".to_string(),
514            decisions: vec![DecisionEntry {
515                what: "Use HashMap".to_string(),
516                why: "Performance".to_string(),
517                stability: "provisional".to_string(),
518                revisit_when: Some("If ordering needed".to_string()),
519                scope: vec!["src/store.rs".to_string()],
520                commit: "abc123".to_string(),
521                timestamp: "2025-01-01T00:00:00Z".to_string(),
522            }],
523            rejected_alternatives: vec![RejectedAlternativeEntry {
524                approach: "BTreeMap".to_string(),
525                reason: "Slower lookups".to_string(),
526                commit: "abc123".to_string(),
527                timestamp: "2025-01-01T00:00:00Z".to_string(),
528            }],
529        };
530
531        let json = serde_json::to_string(&output).unwrap();
532        assert!(json.contains("chronicle-decisions/v1"));
533        assert!(json.contains("Use HashMap"));
534        assert!(json.contains("BTreeMap"));
535    }
536
537    #[test]
538    fn test_decision_scope_matches_helper() {
539        let decision = v2::Decision {
540            what: "test".to_string(),
541            why: "test".to_string(),
542            stability: v2::Stability::Permanent,
543            revisit_when: None,
544            scope: vec!["src/main.rs:main".to_string()],
545        };
546
547        assert!(decision_scope_matches(&decision, "src/main.rs"));
548        assert!(!decision_scope_matches(&decision, "src/other.rs"));
549    }
550
551    #[test]
552    fn test_decision_empty_scope_matches_any_file() {
553        let decision = v2::Decision {
554            what: "test".to_string(),
555            why: "test".to_string(),
556            stability: v2::Stability::Permanent,
557            revisit_when: None,
558            scope: vec![],
559        };
560
561        assert!(decision_scope_matches(&decision, "src/main.rs"));
562        assert!(decision_scope_matches(&decision, "src/anything.rs"));
563    }
564}