Skip to main content

chronicle/read/
lookup.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::knowledge;
4use crate::read::{contracts, decisions, history, staleness};
5use crate::schema;
6use crate::schema::knowledge::FilteredKnowledge;
7
8/// Output of the composite lookup query.
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct LookupOutput {
11    pub schema: String,
12    pub file: String,
13    pub contracts: Vec<contracts::ContractEntry>,
14    pub dependencies: Vec<contracts::DependencyEntry>,
15    pub decisions: Vec<decisions::DecisionEntry>,
16    pub recent_history: Vec<history::TimelineEntry>,
17    pub open_follow_ups: Vec<FollowUpEntry>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub staleness: Vec<staleness::StalenessInfo>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub knowledge: Option<FilteredKnowledge>,
22}
23
24/// A follow-up entry from a recent annotation.
25#[derive(Debug, Clone, serde::Serialize)]
26pub struct FollowUpEntry {
27    pub commit: String,
28    pub follow_up: String,
29}
30
31/// Build a composite context view for a file (contracts + decisions + history + follow-ups).
32pub fn build_lookup(
33    git: &dyn GitOps,
34    file: &str,
35    anchor: Option<&str>,
36) -> Result<LookupOutput, GitError> {
37    // 1. Contracts
38    let contracts_out = contracts::query_contracts(
39        git,
40        &contracts::ContractsQuery {
41            file: file.to_string(),
42            anchor: anchor.map(|s| s.to_string()),
43        },
44    )?;
45
46    // 2. Decisions
47    let decisions_out = decisions::query_decisions(
48        git,
49        &decisions::DecisionsQuery {
50            file: Some(file.to_string()),
51        },
52    )?;
53
54    // 3. Recent history (limit 3)
55    let history_out = history::build_timeline(
56        git,
57        &history::HistoryQuery {
58            file: file.to_string(),
59            anchor: anchor.map(|s| s.to_string()),
60            limit: 3,
61        },
62    )?;
63
64    // 4. Follow-ups from recent annotations
65    let follow_ups = collect_follow_ups(git, file)?;
66
67    // 5. Staleness: for recent annotated commits, compute how stale each is
68    let mut staleness_infos = Vec::new();
69    for entry in &history_out.timeline {
70        if let Some(info) = staleness::compute_staleness(git, file, &entry.commit)? {
71            staleness_infos.push(info);
72        }
73    }
74
75    // 6. Knowledge: filter store by file scope (best-effort, don't fail lookup)
76    let knowledge_filtered = knowledge::read_store(git)
77        .ok()
78        .map(|store| knowledge::filter_by_scope(&store, file))
79        .filter(|k| !k.is_empty());
80
81    Ok(LookupOutput {
82        schema: "chronicle-lookup/v1".to_string(),
83        file: file.to_string(),
84        contracts: contracts_out.contracts,
85        dependencies: contracts_out.dependencies,
86        decisions: decisions_out.decisions,
87        recent_history: history_out.timeline,
88        open_follow_ups: follow_ups,
89        staleness: staleness_infos,
90        knowledge: knowledge_filtered,
91    })
92}
93
94fn collect_follow_ups(git: &dyn GitOps, file: &str) -> Result<Vec<FollowUpEntry>, GitError> {
95    let shas = git.log_for_file(file)?;
96    let mut follow_ups = Vec::new();
97
98    for sha in shas.iter().take(10) {
99        let note = match git.note_read(sha)? {
100            Some(n) => n,
101            None => continue,
102        };
103        let annotation = match schema::parse_annotation(&note) {
104            Ok(a) => a,
105            Err(e) => {
106                tracing::debug!("skipping malformed annotation for {sha}: {e}");
107                continue;
108            }
109        };
110        if let Some(fu) = &annotation.narrative.follow_up {
111            follow_ups.push(FollowUpEntry {
112                commit: sha.clone(),
113                follow_up: fu.clone(),
114            });
115        }
116    }
117
118    Ok(follow_ups)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::schema::v2;
125
126    struct MockGitOps {
127        file_log: Vec<String>,
128        annotated_commits: Vec<String>,
129        notes: std::collections::HashMap<String, String>,
130    }
131
132    impl GitOps for MockGitOps {
133        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
134            Ok(vec![])
135        }
136        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
137            Ok(self.notes.get(commit).cloned())
138        }
139        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
140            Ok(())
141        }
142        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
143            Ok(self.notes.contains_key(commit))
144        }
145        fn file_at_commit(
146            &self,
147            _path: &std::path::Path,
148            _commit: &str,
149        ) -> Result<String, GitError> {
150            Ok(String::new())
151        }
152        fn commit_info(&self, commit: &str) -> Result<crate::git::CommitInfo, GitError> {
153            Ok(crate::git::CommitInfo {
154                sha: commit.to_string(),
155                message: "test".to_string(),
156                author_name: "test".to_string(),
157                author_email: "test@test.com".to_string(),
158                timestamp: "2025-01-01T00:00:00Z".to_string(),
159                parent_shas: vec![],
160            })
161        }
162        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
163            Ok("abc123".to_string())
164        }
165        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
166            Ok(None)
167        }
168        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
169            Ok(())
170        }
171        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
172            Ok(self.file_log.clone())
173        }
174        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
175            Ok(self.annotated_commits.clone())
176        }
177    }
178
179    #[test]
180    fn test_lookup_empty() {
181        let git = MockGitOps {
182            file_log: vec![],
183            annotated_commits: vec![],
184            notes: std::collections::HashMap::new(),
185        };
186
187        let result = build_lookup(&git, "src/main.rs", None).unwrap();
188        assert_eq!(result.schema, "chronicle-lookup/v1");
189        assert_eq!(result.file, "src/main.rs");
190        assert!(result.contracts.is_empty());
191        assert!(result.dependencies.is_empty());
192        assert!(result.decisions.is_empty());
193        assert!(result.recent_history.is_empty());
194        assert!(result.open_follow_ups.is_empty());
195    }
196
197    #[test]
198    fn test_lookup_collects_follow_ups() {
199        let ann = v2::Annotation {
200            schema: "chronicle/v2".to_string(),
201            commit: "commit1".to_string(),
202            timestamp: "2025-01-01T00:00:00Z".to_string(),
203            narrative: v2::Narrative {
204                summary: "test change".to_string(),
205                motivation: None,
206                rejected_alternatives: vec![],
207                follow_up: Some("Need to add error handling".to_string()),
208                files_changed: vec!["src/main.rs".to_string()],
209            },
210            decisions: vec![],
211            markers: vec![],
212            effort: None,
213            provenance: v2::Provenance {
214                source: v2::ProvenanceSource::Live,
215                author: None,
216                derived_from: vec![],
217                notes: None,
218            },
219        };
220
221        let mut notes = std::collections::HashMap::new();
222        notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
223
224        let git = MockGitOps {
225            file_log: vec!["commit1".to_string()],
226            annotated_commits: vec![],
227            notes,
228        };
229
230        let result = build_lookup(&git, "src/main.rs", None).unwrap();
231        assert_eq!(result.open_follow_ups.len(), 1);
232        assert_eq!(
233            result.open_follow_ups[0].follow_up,
234            "Need to add error handling"
235        );
236        assert_eq!(result.open_follow_ups[0].commit, "commit1");
237    }
238
239    #[test]
240    fn test_lookup_combines_contracts_and_history() {
241        let ann = v2::Annotation {
242            schema: "chronicle/v2".to_string(),
243            commit: "commit1".to_string(),
244            timestamp: "2025-01-01T00:00:00Z".to_string(),
245            narrative: v2::Narrative {
246                summary: "add validation".to_string(),
247                motivation: None,
248                rejected_alternatives: vec![],
249                follow_up: None,
250                files_changed: vec!["src/main.rs".to_string()],
251            },
252            decisions: vec![],
253            markers: vec![v2::CodeMarker {
254                file: "src/main.rs".to_string(),
255                anchor: Some(crate::schema::common::AstAnchor {
256                    unit_type: "fn".to_string(),
257                    name: "validate".to_string(),
258                    signature: None,
259                }),
260                lines: None,
261                kind: v2::MarkerKind::Contract {
262                    description: "must not panic".to_string(),
263                    source: v2::ContractSource::Author,
264                },
265            }],
266            effort: None,
267            provenance: v2::Provenance {
268                source: v2::ProvenanceSource::Live,
269                author: None,
270                derived_from: vec![],
271                notes: None,
272            },
273        };
274
275        let mut notes = std::collections::HashMap::new();
276        notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
277
278        let git = MockGitOps {
279            file_log: vec!["commit1".to_string()],
280            annotated_commits: vec![],
281            notes,
282        };
283
284        let result = build_lookup(&git, "src/main.rs", None).unwrap();
285        assert_eq!(result.contracts.len(), 1);
286        assert_eq!(result.contracts[0].description, "must not panic");
287        assert_eq!(result.recent_history.len(), 1);
288        assert_eq!(result.recent_history[0].intent, "add validation");
289    }
290}