1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::annotation::Annotation;
4
5#[derive(Debug, Clone)]
7pub struct DepsQuery {
8 pub file: String,
9 pub anchor: Option<String>,
10 pub max_results: u32,
11 pub scan_limit: u32,
12}
13
14#[derive(Debug, Clone, serde::Serialize)]
16pub struct DependentEntry {
17 pub file: String,
18 pub anchor: String,
19 pub nature: String,
20 pub commit: String,
21 pub timestamp: String,
22 pub context_level: String,
23}
24
25#[derive(Debug, Clone, serde::Serialize)]
27pub struct DepsStats {
28 pub commits_scanned: u32,
29 pub dependencies_found: u32,
30 pub scan_method: String,
31}
32
33#[derive(Debug, Clone, serde::Serialize)]
35pub struct DepsOutput {
36 pub schema: String,
37 pub query: QueryEcho,
38 pub dependents: Vec<DependentEntry>,
39 pub stats: DepsStats,
40}
41
42#[derive(Debug, Clone, serde::Serialize)]
44pub struct QueryEcho {
45 pub file: String,
46 pub anchor: Option<String>,
47}
48
49pub fn find_dependents(git: &dyn GitOps, query: &DepsQuery) -> Result<DepsOutput, GitError> {
54 let annotated = git.list_annotated_commits(query.scan_limit)?;
55 let commits_scanned = annotated.len() as u32;
56
57 let mut dependents: Vec<DependentEntry> = Vec::new();
58
59 for sha in &annotated {
60 let note = match git.note_read(sha)? {
61 Some(n) => n,
62 None => continue,
63 };
64
65 let annotation: Annotation = match serde_json::from_str(¬e) {
66 Ok(a) => a,
67 Err(_) => continue,
68 };
69
70 for region in &annotation.regions {
71 for dep in ®ion.semantic_dependencies {
72 if dep_matches(dep, &query.file, query.anchor.as_deref()) {
73 dependents.push(DependentEntry {
74 file: region.file.clone(),
75 anchor: region.ast_anchor.name.clone(),
76 nature: dep.nature.clone(),
77 commit: sha.clone(),
78 timestamp: annotation.timestamp.clone(),
79 context_level: format!("{:?}", annotation.context_level).to_lowercase(),
80 });
81 }
82 }
83 }
84 }
85
86 deduplicate(&mut dependents);
88
89 dependents.truncate(query.max_results as usize);
91
92 let dependencies_found = dependents.len() as u32;
93
94 Ok(DepsOutput {
95 schema: "chronicle-deps/v1".to_string(),
96 query: QueryEcho {
97 file: query.file.clone(),
98 anchor: query.anchor.clone(),
99 },
100 dependents,
101 stats: DepsStats {
102 commits_scanned,
103 dependencies_found,
104 scan_method: "linear".to_string(),
105 },
106 })
107}
108
109fn dep_matches(
112 dep: &crate::schema::annotation::SemanticDependency,
113 query_file: &str,
114 query_anchor: Option<&str>,
115) -> bool {
116 if !file_matches(&dep.file, query_file) {
117 return false;
118 }
119 match query_anchor {
120 None => true,
121 Some(qa) => anchor_matches(&dep.anchor, qa),
122 }
123}
124
125fn file_matches(a: &str, b: &str) -> bool {
126 fn norm(s: &str) -> &str {
127 s.strip_prefix("./").unwrap_or(s)
128 }
129 norm(a) == norm(b)
130}
131
132fn anchor_matches(dep_anchor: &str, query_anchor: &str) -> bool {
136 if dep_anchor == query_anchor {
137 return true;
138 }
139 let dep_short = dep_anchor.rsplit("::").next().unwrap_or(dep_anchor);
141 let query_short = query_anchor.rsplit("::").next().unwrap_or(query_anchor);
142 dep_short == query_anchor || dep_anchor == query_short || dep_short == query_short
143}
144
145fn deduplicate(dependents: &mut Vec<DependentEntry>) {
148 let mut seen = std::collections::HashSet::new();
149 dependents.retain(|entry| {
150 let key = (entry.file.clone(), entry.anchor.clone());
151 seen.insert(key)
152 });
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::schema::annotation::*;
159
160 struct MockGitOps {
161 annotated_commits: Vec<String>,
162 notes: std::collections::HashMap<String, String>,
163 }
164
165 impl GitOps for MockGitOps {
166 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
167 Ok(vec![])
168 }
169 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
170 Ok(self.notes.get(commit).cloned())
171 }
172 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
173 Ok(())
174 }
175 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
176 Ok(self.notes.contains_key(commit))
177 }
178 fn file_at_commit(
179 &self,
180 _path: &std::path::Path,
181 _commit: &str,
182 ) -> Result<String, GitError> {
183 Ok(String::new())
184 }
185 fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
186 Ok(crate::git::CommitInfo {
187 sha: "abc123".to_string(),
188 message: "test".to_string(),
189 author_name: "test".to_string(),
190 author_email: "test@test.com".to_string(),
191 timestamp: "2025-01-01T00:00:00Z".to_string(),
192 parent_shas: vec![],
193 })
194 }
195 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
196 Ok("abc123".to_string())
197 }
198 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
199 Ok(None)
200 }
201 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
202 Ok(())
203 }
204 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
205 Ok(vec![])
206 }
207 fn list_annotated_commits(&self, limit: u32) -> Result<Vec<String>, GitError> {
208 Ok(self
209 .annotated_commits
210 .iter()
211 .take(limit as usize)
212 .cloned()
213 .collect())
214 }
215 }
216
217 fn make_annotation(
218 commit: &str,
219 timestamp: &str,
220 regions: Vec<RegionAnnotation>,
221 ) -> Annotation {
222 Annotation {
223 schema: "chronicle/v1".to_string(),
224 commit: commit.to_string(),
225 timestamp: timestamp.to_string(),
226 task: None,
227 summary: "test".to_string(),
228 context_level: ContextLevel::Enhanced,
229 regions,
230 cross_cutting: vec![],
231 provenance: Provenance {
232 operation: ProvenanceOperation::Initial,
233 derived_from: vec![],
234 original_annotations_preserved: false,
235 synthesis_notes: None,
236 },
237 }
238 }
239
240 fn make_region(file: &str, anchor: &str, deps: Vec<SemanticDependency>) -> RegionAnnotation {
241 RegionAnnotation {
242 file: file.to_string(),
243 ast_anchor: AstAnchor {
244 unit_type: "fn".to_string(),
245 name: anchor.to_string(),
246 signature: None,
247 },
248 lines: LineRange { start: 1, end: 10 },
249 intent: "test".to_string(),
250 reasoning: None,
251 constraints: vec![],
252 semantic_dependencies: deps,
253 related_annotations: vec![],
254 tags: vec![],
255 risk_notes: None,
256 corrections: vec![],
257 }
258 }
259
260 #[test]
261 fn test_finds_dependency() {
262 let annotation = make_annotation(
263 "commit1",
264 "2025-01-01T00:00:00Z",
265 vec![make_region(
266 "src/mqtt/reconnect.rs",
267 "ReconnectHandler::attempt",
268 vec![SemanticDependency {
269 file: "src/tls/session.rs".to_string(),
270 anchor: "TlsSessionCache::max_sessions".to_string(),
271 nature: "assumes max_sessions is 4".to_string(),
272 }],
273 )],
274 );
275
276 let mut notes = std::collections::HashMap::new();
277 notes.insert(
278 "commit1".to_string(),
279 serde_json::to_string(&annotation).unwrap(),
280 );
281
282 let git = MockGitOps {
283 annotated_commits: vec!["commit1".to_string()],
284 notes,
285 };
286
287 let query = DepsQuery {
288 file: "src/tls/session.rs".to_string(),
289 anchor: Some("TlsSessionCache::max_sessions".to_string()),
290 max_results: 50,
291 scan_limit: 500,
292 };
293
294 let result = find_dependents(&git, &query).unwrap();
295 assert_eq!(result.dependents.len(), 1);
296 assert_eq!(result.dependents[0].file, "src/mqtt/reconnect.rs");
297 assert_eq!(result.dependents[0].anchor, "ReconnectHandler::attempt");
298 assert_eq!(result.dependents[0].nature, "assumes max_sessions is 4");
299 }
300
301 #[test]
302 fn test_no_dependencies() {
303 let annotation = make_annotation(
304 "commit1",
305 "2025-01-01T00:00:00Z",
306 vec![make_region(
307 "src/mqtt/reconnect.rs",
308 "ReconnectHandler::attempt",
309 vec![],
310 )],
311 );
312
313 let mut notes = std::collections::HashMap::new();
314 notes.insert(
315 "commit1".to_string(),
316 serde_json::to_string(&annotation).unwrap(),
317 );
318
319 let git = MockGitOps {
320 annotated_commits: vec!["commit1".to_string()],
321 notes,
322 };
323
324 let query = DepsQuery {
325 file: "src/tls/session.rs".to_string(),
326 anchor: Some("max_sessions".to_string()),
327 max_results: 50,
328 scan_limit: 500,
329 };
330
331 let result = find_dependents(&git, &query).unwrap();
332 assert_eq!(result.dependents.len(), 0);
333 assert_eq!(result.stats.dependencies_found, 0);
334 }
335
336 #[test]
337 fn test_unqualified_anchor_match() {
338 let annotation = make_annotation(
339 "commit1",
340 "2025-01-01T00:00:00Z",
341 vec![make_region(
342 "src/mqtt/reconnect.rs",
343 "ReconnectHandler::attempt",
344 vec![SemanticDependency {
345 file: "src/tls/session.rs".to_string(),
346 anchor: "max_sessions".to_string(),
347 nature: "assumes max_sessions is 4".to_string(),
348 }],
349 )],
350 );
351
352 let mut notes = std::collections::HashMap::new();
353 notes.insert(
354 "commit1".to_string(),
355 serde_json::to_string(&annotation).unwrap(),
356 );
357
358 let git = MockGitOps {
359 annotated_commits: vec!["commit1".to_string()],
360 notes,
361 };
362
363 let query = DepsQuery {
364 file: "src/tls/session.rs".to_string(),
365 anchor: Some("TlsSessionCache::max_sessions".to_string()),
366 max_results: 50,
367 scan_limit: 500,
368 };
369
370 let result = find_dependents(&git, &query).unwrap();
371 assert_eq!(result.dependents.len(), 1);
372 }
373
374 #[test]
375 fn test_multiple_dependents_from_different_commits() {
376 let ann1 = make_annotation(
377 "commit1",
378 "2025-01-01T00:00:00Z",
379 vec![make_region(
380 "src/a.rs",
381 "fn_a",
382 vec![SemanticDependency {
383 file: "src/shared.rs".to_string(),
384 anchor: "shared_fn".to_string(),
385 nature: "calls shared_fn".to_string(),
386 }],
387 )],
388 );
389 let ann2 = make_annotation(
390 "commit2",
391 "2025-01-02T00:00:00Z",
392 vec![make_region(
393 "src/b.rs",
394 "fn_b",
395 vec![SemanticDependency {
396 file: "src/shared.rs".to_string(),
397 anchor: "shared_fn".to_string(),
398 nature: "uses shared_fn return value".to_string(),
399 }],
400 )],
401 );
402
403 let mut notes = std::collections::HashMap::new();
404 notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
405 notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
406
407 let git = MockGitOps {
408 annotated_commits: vec!["commit2".to_string(), "commit1".to_string()],
409 notes,
410 };
411
412 let query = DepsQuery {
413 file: "src/shared.rs".to_string(),
414 anchor: Some("shared_fn".to_string()),
415 max_results: 50,
416 scan_limit: 500,
417 };
418
419 let result = find_dependents(&git, &query).unwrap();
420 assert_eq!(result.dependents.len(), 2);
421 }
422
423 #[test]
424 fn test_deduplicates_same_file_anchor() {
425 let ann1 = make_annotation(
427 "commit1",
428 "2025-01-01T00:00:00Z",
429 vec![make_region(
430 "src/a.rs",
431 "fn_a",
432 vec![SemanticDependency {
433 file: "src/shared.rs".to_string(),
434 anchor: "shared_fn".to_string(),
435 nature: "old nature".to_string(),
436 }],
437 )],
438 );
439 let ann2 = make_annotation(
440 "commit2",
441 "2025-01-02T00:00:00Z",
442 vec![make_region(
443 "src/a.rs",
444 "fn_a",
445 vec![SemanticDependency {
446 file: "src/shared.rs".to_string(),
447 anchor: "shared_fn".to_string(),
448 nature: "new nature".to_string(),
449 }],
450 )],
451 );
452
453 let mut notes = std::collections::HashMap::new();
454 notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
455 notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
456
457 let git = MockGitOps {
458 annotated_commits: vec!["commit2".to_string(), "commit1".to_string()],
460 notes,
461 };
462
463 let query = DepsQuery {
464 file: "src/shared.rs".to_string(),
465 anchor: Some("shared_fn".to_string()),
466 max_results: 50,
467 scan_limit: 500,
468 };
469
470 let result = find_dependents(&git, &query).unwrap();
471 assert_eq!(result.dependents.len(), 1);
472 assert_eq!(result.dependents[0].nature, "new nature");
474 }
475
476 #[test]
477 fn test_scan_limit_respected() {
478 let ann = make_annotation(
479 "commit1",
480 "2025-01-01T00:00:00Z",
481 vec![make_region(
482 "src/a.rs",
483 "fn_a",
484 vec![SemanticDependency {
485 file: "src/shared.rs".to_string(),
486 anchor: "shared_fn".to_string(),
487 nature: "test".to_string(),
488 }],
489 )],
490 );
491
492 let mut notes = std::collections::HashMap::new();
493 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
494
495 let git = MockGitOps {
496 annotated_commits: vec!["commit1".to_string()],
497 notes,
498 };
499
500 let query = DepsQuery {
502 file: "src/shared.rs".to_string(),
503 anchor: Some("shared_fn".to_string()),
504 max_results: 50,
505 scan_limit: 0,
506 };
507
508 let result = find_dependents(&git, &query).unwrap();
509 assert_eq!(result.dependents.len(), 0);
510 assert_eq!(result.stats.commits_scanned, 0);
511 }
512
513 #[test]
514 fn test_max_results_cap() {
515 let ann = make_annotation(
516 "commit1",
517 "2025-01-01T00:00:00Z",
518 vec![
519 make_region(
520 "src/a.rs",
521 "fn_a",
522 vec![SemanticDependency {
523 file: "src/shared.rs".to_string(),
524 anchor: "shared_fn".to_string(),
525 nature: "dep 1".to_string(),
526 }],
527 ),
528 make_region(
529 "src/b.rs",
530 "fn_b",
531 vec![SemanticDependency {
532 file: "src/shared.rs".to_string(),
533 anchor: "shared_fn".to_string(),
534 nature: "dep 2".to_string(),
535 }],
536 ),
537 make_region(
538 "src/c.rs",
539 "fn_c",
540 vec![SemanticDependency {
541 file: "src/shared.rs".to_string(),
542 anchor: "shared_fn".to_string(),
543 nature: "dep 3".to_string(),
544 }],
545 ),
546 ],
547 );
548
549 let mut notes = std::collections::HashMap::new();
550 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
551
552 let git = MockGitOps {
553 annotated_commits: vec!["commit1".to_string()],
554 notes,
555 };
556
557 let query = DepsQuery {
558 file: "src/shared.rs".to_string(),
559 anchor: Some("shared_fn".to_string()),
560 max_results: 2,
561 scan_limit: 500,
562 };
563
564 let result = find_dependents(&git, &query).unwrap();
565 assert_eq!(result.dependents.len(), 2);
566 }
567
568 #[test]
569 fn test_file_only_query() {
570 let annotation = make_annotation(
571 "commit1",
572 "2025-01-01T00:00:00Z",
573 vec![make_region(
574 "src/mqtt/reconnect.rs",
575 "ReconnectHandler::attempt",
576 vec![SemanticDependency {
577 file: "src/tls/session.rs".to_string(),
578 anchor: "TlsSessionCache::max_sessions".to_string(),
579 nature: "assumes max_sessions is 4".to_string(),
580 }],
581 )],
582 );
583
584 let mut notes = std::collections::HashMap::new();
585 notes.insert(
586 "commit1".to_string(),
587 serde_json::to_string(&annotation).unwrap(),
588 );
589
590 let git = MockGitOps {
591 annotated_commits: vec!["commit1".to_string()],
592 notes,
593 };
594
595 let query = DepsQuery {
597 file: "src/tls/session.rs".to_string(),
598 anchor: None,
599 max_results: 50,
600 scan_limit: 500,
601 };
602
603 let result = find_dependents(&git, &query).unwrap();
604 assert_eq!(result.dependents.len(), 1);
605 }
606
607 #[test]
608 fn test_anchor_matches_exact() {
609 assert!(anchor_matches("max_sessions", "max_sessions"));
610 }
611
612 #[test]
613 fn test_anchor_matches_unqualified_dep() {
614 assert!(anchor_matches(
615 "max_sessions",
616 "TlsSessionCache::max_sessions"
617 ));
618 }
619
620 #[test]
621 fn test_anchor_matches_unqualified_query() {
622 assert!(anchor_matches(
623 "TlsSessionCache::max_sessions",
624 "max_sessions"
625 ));
626 }
627
628 #[test]
629 fn test_anchor_no_match() {
630 assert!(!anchor_matches("other_fn", "max_sessions"));
631 }
632}