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#[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#[derive(Debug, Clone, serde::Serialize)]
26pub struct FollowUpEntry {
27 pub commit: String,
28 pub follow_up: String,
29}
30
31pub fn build_lookup(
33 git: &dyn GitOps,
34 file: &str,
35 anchor: Option<&str>,
36) -> Result<LookupOutput, GitError> {
37 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 let decisions_out = decisions::query_decisions(
48 git,
49 &decisions::DecisionsQuery {
50 file: Some(file.to_string()),
51 },
52 )?;
53
54 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 let follow_ups = collect_follow_ups(git, file)?;
66
67 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 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(¬e) {
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}