1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
4
5#[derive(Debug, Clone)]
7pub struct ContractsQuery {
8 pub file: String,
9 pub anchor: Option<String>,
10}
11
12#[derive(Debug, Clone, serde::Serialize)]
14pub struct ContractsQueryEcho {
15 pub file: String,
16 pub anchor: Option<String>,
17}
18
19#[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#[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#[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
51pub 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 let mut best_contracts: std::collections::HashMap<String, ContractEntry> =
65 std::collections::HashMap::new();
66 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(¬e) {
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 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 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 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 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 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}