1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
4
5#[derive(Debug, Clone)]
7pub struct DecisionsQuery {
8 pub file: Option<String>,
9}
10
11#[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#[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#[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
40pub 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 let mut best_decisions: std::collections::HashMap<String, DecisionEntry> =
60 std::collections::HashMap::new();
61 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(¬e) {
72 Ok(a) => a,
73 Err(e) => {
74 tracing::debug!("skipping malformed annotation for {sha}: {e}");
75 continue;
76 }
77 };
78
79 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 for rejected in &annotation.narrative.rejected_alternatives {
103 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
132fn 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 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 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 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 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 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 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 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 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}