1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
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(
62 git: &dyn GitOps,
63 query: &ContractsQuery,
64) -> Result<ContractsOutput, GitError> {
65 let shas = git.log_for_file(&query.file)?;
66
67 let mut best_contracts: std::collections::HashMap<String, ContractEntry> =
69 std::collections::HashMap::new();
70 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(¬e) {
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 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 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
155fn 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 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); 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 assert_eq!(result.contracts.len(), 2);
404 }
405
406 #[test]
407 fn test_contracts_dedup_keeps_newest() {
408 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 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 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}