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 for w in &annotation.wisdom {
112 if w.category == crate::schema::v3::WisdomCategory::UnfinishedThread {
113 follow_ups.push(FollowUpEntry {
114 commit: sha.clone(),
115 follow_up: w.content.clone(),
116 });
117 }
118 }
119 }
120
121 Ok(follow_ups)
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::schema::v2;
128
129 struct MockGitOps {
130 file_log: Vec<String>,
131 annotated_commits: Vec<String>,
132 notes: std::collections::HashMap<String, String>,
133 }
134
135 impl GitOps for MockGitOps {
136 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
137 Ok(vec![])
138 }
139 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
140 Ok(self.notes.get(commit).cloned())
141 }
142 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
143 Ok(())
144 }
145 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
146 Ok(self.notes.contains_key(commit))
147 }
148 fn file_at_commit(
149 &self,
150 _path: &std::path::Path,
151 _commit: &str,
152 ) -> Result<String, GitError> {
153 Ok(String::new())
154 }
155 fn commit_info(&self, commit: &str) -> Result<crate::git::CommitInfo, GitError> {
156 Ok(crate::git::CommitInfo {
157 sha: commit.to_string(),
158 message: "test".to_string(),
159 author_name: "test".to_string(),
160 author_email: "test@test.com".to_string(),
161 timestamp: "2025-01-01T00:00:00Z".to_string(),
162 parent_shas: vec![],
163 })
164 }
165 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
166 Ok("abc123".to_string())
167 }
168 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
169 Ok(None)
170 }
171 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
172 Ok(())
173 }
174 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
175 Ok(self.file_log.clone())
176 }
177 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
178 Ok(self.annotated_commits.clone())
179 }
180 }
181
182 #[test]
183 fn test_lookup_empty() {
184 let git = MockGitOps {
185 file_log: vec![],
186 annotated_commits: vec![],
187 notes: std::collections::HashMap::new(),
188 };
189
190 let result = build_lookup(&git, "src/main.rs", None).unwrap();
191 assert_eq!(result.schema, "chronicle-lookup/v1");
192 assert_eq!(result.file, "src/main.rs");
193 assert!(result.contracts.is_empty());
194 assert!(result.dependencies.is_empty());
195 assert!(result.decisions.is_empty());
196 assert!(result.recent_history.is_empty());
197 assert!(result.open_follow_ups.is_empty());
198 }
199
200 #[test]
201 fn test_lookup_collects_follow_ups() {
202 let ann = v2::Annotation {
203 schema: "chronicle/v2".to_string(),
204 commit: "commit1".to_string(),
205 timestamp: "2025-01-01T00:00:00Z".to_string(),
206 narrative: v2::Narrative {
207 summary: "test change".to_string(),
208 motivation: None,
209 rejected_alternatives: vec![],
210 follow_up: Some("Need to add error handling".to_string()),
211 files_changed: vec!["src/main.rs".to_string()],
212 sentiments: vec![],
213 },
214 decisions: vec![],
215 markers: vec![],
216 effort: None,
217 provenance: v2::Provenance {
218 source: v2::ProvenanceSource::Live,
219 author: None,
220 derived_from: vec![],
221 notes: None,
222 },
223 };
224
225 let mut notes = std::collections::HashMap::new();
226 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
227
228 let git = MockGitOps {
229 file_log: vec!["commit1".to_string()],
230 annotated_commits: vec![],
231 notes,
232 };
233
234 let result = build_lookup(&git, "src/main.rs", None).unwrap();
235 assert_eq!(result.open_follow_ups.len(), 1);
236 assert_eq!(
237 result.open_follow_ups[0].follow_up,
238 "Need to add error handling"
239 );
240 assert_eq!(result.open_follow_ups[0].commit, "commit1");
241 }
242
243 #[test]
244 fn test_lookup_combines_contracts_and_history() {
245 let ann = v2::Annotation {
246 schema: "chronicle/v2".to_string(),
247 commit: "commit1".to_string(),
248 timestamp: "2025-01-01T00:00:00Z".to_string(),
249 narrative: v2::Narrative {
250 summary: "add validation".to_string(),
251 motivation: None,
252 rejected_alternatives: vec![],
253 follow_up: None,
254 files_changed: vec!["src/main.rs".to_string()],
255 sentiments: vec![],
256 },
257 decisions: vec![],
258 markers: vec![v2::CodeMarker {
259 file: "src/main.rs".to_string(),
260 anchor: Some(crate::schema::common::AstAnchor {
261 unit_type: "fn".to_string(),
262 name: "validate".to_string(),
263 signature: None,
264 }),
265 lines: None,
266 kind: v2::MarkerKind::Contract {
267 description: "must not panic".to_string(),
268 source: v2::ContractSource::Author,
269 },
270 }],
271 effort: None,
272 provenance: v2::Provenance {
273 source: v2::ProvenanceSource::Live,
274 author: None,
275 derived_from: vec![],
276 notes: None,
277 },
278 };
279
280 let mut notes = std::collections::HashMap::new();
281 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
282
283 let git = MockGitOps {
284 file_log: vec!["commit1".to_string()],
285 annotated_commits: vec![],
286 notes,
287 };
288
289 let result = build_lookup(&git, "src/main.rs", None).unwrap();
290 assert_eq!(result.contracts.len(), 1);
291 assert_eq!(result.contracts[0].description, "must not panic");
292 assert_eq!(result.recent_history.len(), 1);
293 assert_eq!(result.recent_history[0].intent, "add validation");
294 }
295}