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        // In v3, follow-ups are unfinished_thread wisdom entries.
111        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}