Skip to main content

chronicle/read/
contracts.rs

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