Skip to main content

chronicle/read/
contracts.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
4
5/// Query parameters: "What must I not break?" for a file/anchor.
6#[derive(Debug, Clone)]
7pub struct ContractsQuery {
8    pub file: String,
9    pub anchor: Option<String>,
10}
11
12/// Echo of the query in the output.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct ContractsQueryEcho {
15    pub file: String,
16    pub anchor: Option<String>,
17}
18
19/// A contract entry extracted from gotcha wisdom entries.
20#[derive(Debug, Clone, serde::Serialize)]
21pub struct ContractEntry {
22    pub file: String,
23    pub anchor: Option<String>,
24    pub description: String,
25    pub source: String,
26    pub commit: String,
27    pub timestamp: String,
28}
29
30/// A dependency entry extracted from insight wisdom entries with dependency content.
31#[derive(Debug, Clone, serde::Serialize)]
32pub struct DependencyEntry {
33    pub file: String,
34    pub anchor: Option<String>,
35    pub target_file: String,
36    pub target_anchor: String,
37    pub assumption: String,
38    pub commit: String,
39    pub timestamp: String,
40}
41
42/// Output of a contracts query.
43#[derive(Debug, Clone, serde::Serialize)]
44pub struct ContractsOutput {
45    pub schema: String,
46    pub query: ContractsQueryEcho,
47    pub contracts: Vec<ContractEntry>,
48    pub dependencies: Vec<DependencyEntry>,
49}
50
51/// Build a contracts-and-dependencies view for a file (or file+anchor).
52///
53/// In v3, contracts come from `gotcha` wisdom entries and dependencies
54/// from `insight` wisdom entries whose content matches the dependency pattern
55/// ("Depends on <file>:<anchor> — <assumption>").
56///
57/// 1. Get commits that touched the file via `log_for_file`
58/// 2. For each commit, parse annotation via `parse_annotation`
59/// 3. Filter wisdom entries matching the query file
60/// 4. Deduplicate by keeping the most recent entry per unique key
61pub fn query_contracts(
62    git: &dyn GitOps,
63    query: &ContractsQuery,
64) -> Result<ContractsOutput, GitError> {
65    let shas = git.log_for_file(&query.file)?;
66
67    // Key: (file, description) -> ContractEntry
68    let mut best_contracts: std::collections::HashMap<String, ContractEntry> =
69        std::collections::HashMap::new();
70    // Key: (file, target_file, target_anchor) -> DependencyEntry
71    let mut best_deps: std::collections::HashMap<String, DependencyEntry> =
72        std::collections::HashMap::new();
73
74    for sha in &shas {
75        let note = match git.note_read(sha)? {
76            Some(n) => n,
77            None => continue,
78        };
79
80        let annotation = match schema::parse_annotation(&note) {
81            Ok(a) => a,
82            Err(e) => {
83                tracing::debug!("skipping malformed annotation for {sha}: {e}");
84                continue;
85            }
86        };
87
88        for w in &annotation.wisdom {
89            // Only consider wisdom entries that match the queried file
90            let entry_file = match &w.file {
91                Some(f) => f,
92                None => continue,
93            };
94            if !file_matches(entry_file, &query.file) {
95                continue;
96            }
97
98            match w.category {
99                v3::WisdomCategory::Gotcha => {
100                    let key = format!("{}:{}", entry_file, w.content);
101                    best_contracts.entry(key).or_insert_with(|| ContractEntry {
102                        file: entry_file.clone(),
103                        anchor: None,
104                        description: w.content.clone(),
105                        source: "author".to_string(),
106                        commit: annotation.commit.clone(),
107                        timestamp: annotation.timestamp.clone(),
108                    });
109                }
110                v3::WisdomCategory::Insight => {
111                    // Check if this insight is a dependency entry
112                    // Migration produces: "Depends on {target_file}:{target_anchor} — {assumption}"
113                    if let Some(dep) = parse_dependency_content(&w.content) {
114                        let key = format!("{}:{}:{}", entry_file, dep.0, dep.1);
115                        best_deps.entry(key).or_insert_with(|| DependencyEntry {
116                            file: entry_file.clone(),
117                            anchor: None,
118                            target_file: dep.0.to_string(),
119                            target_anchor: dep.1.to_string(),
120                            assumption: dep.2.to_string(),
121                            commit: annotation.commit.clone(),
122                            timestamp: annotation.timestamp.clone(),
123                        });
124                    }
125                }
126                _ => {}
127            }
128        }
129    }
130
131    let mut contracts: Vec<ContractEntry> = best_contracts.into_values().collect();
132    contracts.sort_by(|a, b| a.file.cmp(&b.file).then(a.description.cmp(&b.description)));
133
134    let mut dependencies: Vec<DependencyEntry> = best_deps.into_values().collect();
135    dependencies.sort_by(|a, b| {
136        a.file
137            .cmp(&b.file)
138            .then(a.target_file.cmp(&b.target_file))
139            .then(a.target_anchor.cmp(&b.target_anchor))
140    });
141
142    Ok(ContractsOutput {
143        schema: "chronicle-contracts/v1".to_string(),
144        query: ContractsQueryEcho {
145            file: query.file.clone(),
146            anchor: query.anchor.clone(),
147        },
148        contracts,
149        dependencies,
150    })
151}
152
153use super::matching::file_matches;
154
155/// Parse dependency content from the migration format:
156/// "Depends on {file}:{anchor} — {assumption}"
157/// Returns (target_file, target_anchor, assumption) if matched.
158fn parse_dependency_content(content: &str) -> Option<(&str, &str, &str)> {
159    let rest = content.strip_prefix("Depends on ")?;
160    let (target, assumption) = rest.split_once(" — ")?;
161    let (target_file, target_anchor) = target.split_once(':')?;
162    Some((target_file, target_anchor, assumption))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::schema::common::{AstAnchor, LineRange};
169    use crate::schema::v1::{
170        Constraint, ConstraintSource, ContextLevel, Provenance, ProvenanceOperation,
171        RegionAnnotation, SemanticDependency,
172    };
173
174    struct MockGitOps {
175        file_log: Vec<String>,
176        notes: std::collections::HashMap<String, String>,
177    }
178
179    impl GitOps for MockGitOps {
180        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
181            Ok(vec![])
182        }
183        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
184            Ok(self.notes.get(commit).cloned())
185        }
186        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
187            Ok(())
188        }
189        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
190            Ok(self.notes.contains_key(commit))
191        }
192        fn file_at_commit(
193            &self,
194            _path: &std::path::Path,
195            _commit: &str,
196        ) -> Result<String, GitError> {
197            Ok(String::new())
198        }
199        fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
200            Ok(crate::git::CommitInfo {
201                sha: "abc123".to_string(),
202                message: "test".to_string(),
203                author_name: "test".to_string(),
204                author_email: "test@test.com".to_string(),
205                timestamp: "2025-01-01T00:00:00Z".to_string(),
206                parent_shas: vec![],
207            })
208        }
209        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
210            Ok("abc123".to_string())
211        }
212        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
213            Ok(None)
214        }
215        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
216            Ok(())
217        }
218        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
219            Ok(self.file_log.clone())
220        }
221        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
222            Ok(vec![])
223        }
224    }
225
226    /// Build a v1 annotation (serialized as JSON). parse_annotation() will
227    /// migrate it to v3, exercising the migration path in the test.
228    fn make_v1_annotation(commit: &str, timestamp: &str, regions: Vec<RegionAnnotation>) -> String {
229        let ann = crate::schema::v1::Annotation {
230            schema: "chronicle/v1".to_string(),
231            commit: commit.to_string(),
232            timestamp: timestamp.to_string(),
233            task: None,
234            summary: "test".to_string(),
235            context_level: ContextLevel::Enhanced,
236            regions,
237            cross_cutting: vec![],
238            provenance: Provenance {
239                operation: ProvenanceOperation::Initial,
240                derived_from: vec![],
241                original_annotations_preserved: false,
242                synthesis_notes: None,
243            },
244        };
245        serde_json::to_string(&ann).unwrap()
246    }
247
248    fn make_region_with_contract(
249        file: &str,
250        anchor: &str,
251        constraint_text: &str,
252    ) -> RegionAnnotation {
253        RegionAnnotation {
254            file: file.to_string(),
255            ast_anchor: AstAnchor {
256                unit_type: "function".to_string(),
257                name: anchor.to_string(),
258                signature: None,
259            },
260            lines: LineRange { start: 1, end: 10 },
261            intent: "test intent".to_string(),
262            reasoning: None,
263            constraints: vec![Constraint {
264                text: constraint_text.to_string(),
265                source: ConstraintSource::Author,
266            }],
267            semantic_dependencies: vec![],
268            related_annotations: vec![],
269            tags: vec![],
270            risk_notes: None,
271            corrections: vec![],
272        }
273    }
274
275    fn make_region_with_dependency(
276        file: &str,
277        anchor: &str,
278        target_file: &str,
279        target_anchor: &str,
280        nature: &str,
281    ) -> RegionAnnotation {
282        RegionAnnotation {
283            file: file.to_string(),
284            ast_anchor: AstAnchor {
285                unit_type: "function".to_string(),
286                name: anchor.to_string(),
287                signature: None,
288            },
289            lines: LineRange { start: 1, end: 10 },
290            intent: "test intent".to_string(),
291            reasoning: None,
292            constraints: vec![],
293            semantic_dependencies: vec![SemanticDependency {
294                file: target_file.to_string(),
295                anchor: target_anchor.to_string(),
296                nature: nature.to_string(),
297            }],
298            related_annotations: vec![],
299            tags: vec![],
300            risk_notes: None,
301            corrections: vec![],
302        }
303    }
304
305    #[test]
306    fn test_contracts_from_v1_migration() {
307        let note = make_v1_annotation(
308            "commit1",
309            "2025-01-01T00:00:00Z",
310            vec![make_region_with_contract(
311                "src/main.rs",
312                "main",
313                "must not panic",
314            )],
315        );
316
317        let mut notes = std::collections::HashMap::new();
318        notes.insert("commit1".to_string(), note);
319
320        let git = MockGitOps {
321            file_log: vec!["commit1".to_string()],
322            notes,
323        };
324
325        let query = ContractsQuery {
326            file: "src/main.rs".to_string(),
327            anchor: None,
328        };
329
330        let result = query_contracts(&git, &query).unwrap();
331        assert_eq!(result.schema, "chronicle-contracts/v1");
332        assert_eq!(result.contracts.len(), 1);
333        assert_eq!(result.contracts[0].description, "must not panic");
334        assert_eq!(result.contracts[0].source, "author");
335        assert_eq!(result.contracts[0].file, "src/main.rs");
336        assert_eq!(result.contracts[0].anchor, None); // v3 wisdom entries lose named anchors after migration
337        assert_eq!(result.contracts[0].commit, "commit1");
338    }
339
340    #[test]
341    fn test_dependencies_from_v1_migration() {
342        let note = make_v1_annotation(
343            "commit1",
344            "2025-01-01T00:00:00Z",
345            vec![make_region_with_dependency(
346                "src/main.rs",
347                "main",
348                "src/config.rs",
349                "Config::load",
350                "assumes Config::load returns defaults on missing file",
351            )],
352        );
353
354        let mut notes = std::collections::HashMap::new();
355        notes.insert("commit1".to_string(), note);
356
357        let git = MockGitOps {
358            file_log: vec!["commit1".to_string()],
359            notes,
360        };
361
362        let query = ContractsQuery {
363            file: "src/main.rs".to_string(),
364            anchor: None,
365        };
366
367        let result = query_contracts(&git, &query).unwrap();
368        assert_eq!(result.dependencies.len(), 1);
369        assert_eq!(result.dependencies[0].target_file, "src/config.rs");
370        assert_eq!(result.dependencies[0].target_anchor, "Config::load");
371        assert_eq!(
372            result.dependencies[0].assumption,
373            "assumes Config::load returns defaults on missing file"
374        );
375    }
376
377    #[test]
378    fn test_contracts_with_anchor_filter() {
379        let note = make_v1_annotation(
380            "commit1",
381            "2025-01-01T00:00:00Z",
382            vec![
383                make_region_with_contract("src/main.rs", "main", "must not panic"),
384                make_region_with_contract("src/main.rs", "helper", "must be pure"),
385            ],
386        );
387
388        let mut notes = std::collections::HashMap::new();
389        notes.insert("commit1".to_string(), note);
390
391        let git = MockGitOps {
392            file_log: vec!["commit1".to_string()],
393            notes,
394        };
395
396        let query = ContractsQuery {
397            file: "src/main.rs".to_string(),
398            anchor: Some("main".to_string()),
399        };
400
401        let result = query_contracts(&git, &query).unwrap();
402        // v3 has no anchor-level filtering; both contracts for the file are returned
403        assert_eq!(result.contracts.len(), 2);
404    }
405
406    #[test]
407    fn test_contracts_dedup_keeps_newest() {
408        // Two commits annotating the same function with the same constraint.
409        // Newest first in git log, so commit2 should win.
410        let note1 = make_v1_annotation(
411            "commit1",
412            "2025-01-01T00:00:00Z",
413            vec![make_region_with_contract(
414                "src/main.rs",
415                "main",
416                "must not panic",
417            )],
418        );
419        let note2 = make_v1_annotation(
420            "commit2",
421            "2025-01-02T00:00:00Z",
422            vec![make_region_with_contract(
423                "src/main.rs",
424                "main",
425                "must not panic",
426            )],
427        );
428
429        let mut notes = std::collections::HashMap::new();
430        notes.insert("commit1".to_string(), note1);
431        notes.insert("commit2".to_string(), note2);
432
433        let git = MockGitOps {
434            // newest first
435            file_log: vec!["commit2".to_string(), "commit1".to_string()],
436            notes,
437        };
438
439        let query = ContractsQuery {
440            file: "src/main.rs".to_string(),
441            anchor: None,
442        };
443
444        let result = query_contracts(&git, &query).unwrap();
445        assert_eq!(result.contracts.len(), 1);
446        assert_eq!(result.contracts[0].commit, "commit2");
447        assert_eq!(result.contracts[0].timestamp, "2025-01-02T00:00:00Z");
448    }
449
450    #[test]
451    fn test_contracts_empty_when_no_annotations() {
452        let git = MockGitOps {
453            file_log: vec!["commit1".to_string()],
454            notes: std::collections::HashMap::new(),
455        };
456
457        let query = ContractsQuery {
458            file: "src/main.rs".to_string(),
459            anchor: None,
460        };
461
462        let result = query_contracts(&git, &query).unwrap();
463        assert!(result.contracts.is_empty());
464        assert!(result.dependencies.is_empty());
465    }
466
467    #[test]
468    fn test_contracts_mixed_contracts_and_deps() {
469        let region_contract = make_region_with_contract("src/main.rs", "main", "must not allocate");
470        let region_dep = make_region_with_dependency(
471            "src/main.rs",
472            "main",
473            "src/alloc.rs",
474            "Allocator::new",
475            "assumes Allocator::new never fails",
476        );
477
478        let note = make_v1_annotation(
479            "commit1",
480            "2025-01-01T00:00:00Z",
481            vec![region_contract, region_dep],
482        );
483
484        let mut notes = std::collections::HashMap::new();
485        notes.insert("commit1".to_string(), note);
486
487        let git = MockGitOps {
488            file_log: vec!["commit1".to_string()],
489            notes,
490        };
491
492        let query = ContractsQuery {
493            file: "src/main.rs".to_string(),
494            anchor: None,
495        };
496
497        let result = query_contracts(&git, &query).unwrap();
498        assert_eq!(result.contracts.len(), 1);
499        assert_eq!(result.contracts[0].description, "must not allocate");
500        assert_eq!(result.dependencies.len(), 1);
501        assert_eq!(result.dependencies[0].target_file, "src/alloc.rs");
502    }
503
504    #[test]
505    fn test_contracts_file_path_normalization() {
506        let note = make_v1_annotation(
507            "commit1",
508            "2025-01-01T00:00:00Z",
509            vec![make_region_with_contract(
510                "./src/main.rs",
511                "main",
512                "must not panic",
513            )],
514        );
515
516        let mut notes = std::collections::HashMap::new();
517        notes.insert("commit1".to_string(), note);
518
519        let git = MockGitOps {
520            file_log: vec!["commit1".to_string()],
521            notes,
522        };
523
524        // Query without "./" prefix should still match
525        let query = ContractsQuery {
526            file: "src/main.rs".to_string(),
527            anchor: None,
528        };
529
530        let result = query_contracts(&git, &query).unwrap();
531        assert_eq!(result.contracts.len(), 1);
532    }
533
534    #[test]
535    fn test_contracts_output_serializable() {
536        let output = ContractsOutput {
537            schema: "chronicle-contracts/v1".to_string(),
538            query: ContractsQueryEcho {
539                file: "src/main.rs".to_string(),
540                anchor: None,
541            },
542            contracts: vec![ContractEntry {
543                file: "src/main.rs".to_string(),
544                anchor: Some("main".to_string()),
545                description: "must not panic".to_string(),
546                source: "author".to_string(),
547                commit: "abc123".to_string(),
548                timestamp: "2025-01-01T00:00:00Z".to_string(),
549            }],
550            dependencies: vec![],
551        };
552
553        let json = serde_json::to_string(&output).unwrap();
554        assert!(json.contains("chronicle-contracts/v1"));
555        assert!(json.contains("must not panic"));
556    }
557}