1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
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 = match schema::parse_annotation(¬e) {
66 Ok(a) => a,
67 Err(e) => {
68 tracing::debug!("skipping malformed annotation for {sha}: {e}");
69 continue;
70 }
71 };
72
73 for marker in &annotation.markers {
74 if let v2::MarkerKind::Dependency {
75 target_file,
76 target_anchor,
77 assumption,
78 } = &marker.kind
79 {
80 if dep_matches(
81 target_file,
82 target_anchor,
83 &query.file,
84 query.anchor.as_deref(),
85 ) {
86 let anchor_name = marker
87 .anchor
88 .as_ref()
89 .map(|a| a.name.clone())
90 .unwrap_or_default();
91 dependents.push(DependentEntry {
92 file: marker.file.clone(),
93 anchor: anchor_name,
94 nature: assumption.clone(),
95 commit: sha.clone(),
96 timestamp: annotation.timestamp.clone(),
97 context_level: annotation.provenance.source.to_string(),
98 });
99 }
100 }
101 }
102 }
103
104 deduplicate(&mut dependents);
106
107 dependents.truncate(query.max_results as usize);
109
110 let dependencies_found = dependents.len() as u32;
111
112 Ok(DepsOutput {
113 schema: "chronicle-deps/v1".to_string(),
114 query: QueryEcho {
115 file: query.file.clone(),
116 anchor: query.anchor.clone(),
117 },
118 dependents,
119 stats: DepsStats {
120 commits_scanned,
121 dependencies_found,
122 scan_method: "linear".to_string(),
123 },
124 })
125}
126
127fn dep_matches(
130 target_file: &str,
131 target_anchor: &str,
132 query_file: &str,
133 query_anchor: Option<&str>,
134) -> bool {
135 if !file_matches(target_file, query_file) {
136 return false;
137 }
138 match query_anchor {
139 None => true,
140 Some(qa) => anchor_matches(target_anchor, qa),
141 }
142}
143
144use super::matching::{anchor_matches, file_matches};
145
146fn deduplicate(dependents: &mut Vec<DependentEntry>) {
149 let mut seen = std::collections::HashSet::new();
150 dependents.retain(|entry| {
151 let key = (entry.file.clone(), entry.anchor.clone());
152 seen.insert(key)
153 });
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::schema::common::AstAnchor;
160
161 struct MockGitOps {
162 annotated_commits: Vec<String>,
163 notes: std::collections::HashMap<String, String>,
164 }
165
166 impl GitOps for MockGitOps {
167 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
168 Ok(vec![])
169 }
170 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
171 Ok(self.notes.get(commit).cloned())
172 }
173 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
174 Ok(())
175 }
176 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
177 Ok(self.notes.contains_key(commit))
178 }
179 fn file_at_commit(
180 &self,
181 _path: &std::path::Path,
182 _commit: &str,
183 ) -> Result<String, GitError> {
184 Ok(String::new())
185 }
186 fn commit_info(&self, _commit: &str) -> Result<crate::git::CommitInfo, GitError> {
187 Ok(crate::git::CommitInfo {
188 sha: "abc123".to_string(),
189 message: "test".to_string(),
190 author_name: "test".to_string(),
191 author_email: "test@test.com".to_string(),
192 timestamp: "2025-01-01T00:00:00Z".to_string(),
193 parent_shas: vec![],
194 })
195 }
196 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
197 Ok("abc123".to_string())
198 }
199 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
200 Ok(None)
201 }
202 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
203 Ok(())
204 }
205 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
206 Ok(vec![])
207 }
208 fn list_annotated_commits(&self, limit: u32) -> Result<Vec<String>, GitError> {
209 Ok(self
210 .annotated_commits
211 .iter()
212 .take(limit as usize)
213 .cloned()
214 .collect())
215 }
216 }
217
218 fn make_v2_annotation(commit: &str, timestamp: &str, markers: Vec<v2::CodeMarker>) -> String {
219 let ann = v2::Annotation {
220 schema: "chronicle/v2".to_string(),
221 commit: commit.to_string(),
222 timestamp: timestamp.to_string(),
223 narrative: v2::Narrative {
224 summary: "test".to_string(),
225 motivation: None,
226 rejected_alternatives: vec![],
227 follow_up: None,
228 files_changed: vec![],
229 },
230 decisions: vec![],
231 markers,
232 effort: None,
233 provenance: v2::Provenance {
234 source: v2::ProvenanceSource::Live,
235 author: None,
236 derived_from: vec![],
237 notes: None,
238 },
239 };
240 serde_json::to_string(&ann).unwrap()
241 }
242
243 fn make_dep_marker(
244 file: &str,
245 anchor: &str,
246 target_file: &str,
247 target_anchor: &str,
248 assumption: &str,
249 ) -> v2::CodeMarker {
250 v2::CodeMarker {
251 file: file.to_string(),
252 anchor: Some(AstAnchor {
253 unit_type: "fn".to_string(),
254 name: anchor.to_string(),
255 signature: None,
256 }),
257 lines: None,
258 kind: v2::MarkerKind::Dependency {
259 target_file: target_file.to_string(),
260 target_anchor: target_anchor.to_string(),
261 assumption: assumption.to_string(),
262 },
263 }
264 }
265
266 #[test]
267 fn test_finds_dependency() {
268 let note = make_v2_annotation(
269 "commit1",
270 "2025-01-01T00:00:00Z",
271 vec![make_dep_marker(
272 "src/mqtt/reconnect.rs",
273 "ReconnectHandler::attempt",
274 "src/tls/session.rs",
275 "TlsSessionCache::max_sessions",
276 "assumes max_sessions is 4",
277 )],
278 );
279
280 let mut notes = std::collections::HashMap::new();
281 notes.insert("commit1".to_string(), note);
282
283 let git = MockGitOps {
284 annotated_commits: vec!["commit1".to_string()],
285 notes,
286 };
287
288 let query = DepsQuery {
289 file: "src/tls/session.rs".to_string(),
290 anchor: Some("TlsSessionCache::max_sessions".to_string()),
291 max_results: 50,
292 scan_limit: 500,
293 };
294
295 let result = find_dependents(&git, &query).unwrap();
296 assert_eq!(result.dependents.len(), 1);
297 assert_eq!(result.dependents[0].file, "src/mqtt/reconnect.rs");
298 assert_eq!(result.dependents[0].anchor, "ReconnectHandler::attempt");
299 assert_eq!(result.dependents[0].nature, "assumes max_sessions is 4");
300 }
301
302 #[test]
303 fn test_no_dependencies() {
304 let note = make_v2_annotation("commit1", "2025-01-01T00:00:00Z", vec![]);
305
306 let mut notes = std::collections::HashMap::new();
307 notes.insert("commit1".to_string(), note);
308
309 let git = MockGitOps {
310 annotated_commits: vec!["commit1".to_string()],
311 notes,
312 };
313
314 let query = DepsQuery {
315 file: "src/tls/session.rs".to_string(),
316 anchor: Some("max_sessions".to_string()),
317 max_results: 50,
318 scan_limit: 500,
319 };
320
321 let result = find_dependents(&git, &query).unwrap();
322 assert_eq!(result.dependents.len(), 0);
323 assert_eq!(result.stats.dependencies_found, 0);
324 }
325
326 #[test]
327 fn test_unqualified_anchor_match() {
328 let note = make_v2_annotation(
329 "commit1",
330 "2025-01-01T00:00:00Z",
331 vec![make_dep_marker(
332 "src/mqtt/reconnect.rs",
333 "ReconnectHandler::attempt",
334 "src/tls/session.rs",
335 "max_sessions",
336 "assumes max_sessions is 4",
337 )],
338 );
339
340 let mut notes = std::collections::HashMap::new();
341 notes.insert("commit1".to_string(), note);
342
343 let git = MockGitOps {
344 annotated_commits: vec!["commit1".to_string()],
345 notes,
346 };
347
348 let query = DepsQuery {
349 file: "src/tls/session.rs".to_string(),
350 anchor: Some("TlsSessionCache::max_sessions".to_string()),
351 max_results: 50,
352 scan_limit: 500,
353 };
354
355 let result = find_dependents(&git, &query).unwrap();
356 assert_eq!(result.dependents.len(), 1);
357 }
358
359 #[test]
360 fn test_multiple_dependents_from_different_commits() {
361 let note1 = make_v2_annotation(
362 "commit1",
363 "2025-01-01T00:00:00Z",
364 vec![make_dep_marker(
365 "src/a.rs",
366 "fn_a",
367 "src/shared.rs",
368 "shared_fn",
369 "calls shared_fn",
370 )],
371 );
372 let note2 = make_v2_annotation(
373 "commit2",
374 "2025-01-02T00:00:00Z",
375 vec![make_dep_marker(
376 "src/b.rs",
377 "fn_b",
378 "src/shared.rs",
379 "shared_fn",
380 "uses shared_fn return value",
381 )],
382 );
383
384 let mut notes = std::collections::HashMap::new();
385 notes.insert("commit1".to_string(), note1);
386 notes.insert("commit2".to_string(), note2);
387
388 let git = MockGitOps {
389 annotated_commits: vec!["commit2".to_string(), "commit1".to_string()],
390 notes,
391 };
392
393 let query = DepsQuery {
394 file: "src/shared.rs".to_string(),
395 anchor: Some("shared_fn".to_string()),
396 max_results: 50,
397 scan_limit: 500,
398 };
399
400 let result = find_dependents(&git, &query).unwrap();
401 assert_eq!(result.dependents.len(), 2);
402 }
403
404 #[test]
405 fn test_deduplicates_same_file_anchor() {
406 let note1 = make_v2_annotation(
407 "commit1",
408 "2025-01-01T00:00:00Z",
409 vec![make_dep_marker(
410 "src/a.rs",
411 "fn_a",
412 "src/shared.rs",
413 "shared_fn",
414 "old nature",
415 )],
416 );
417 let note2 = make_v2_annotation(
418 "commit2",
419 "2025-01-02T00:00:00Z",
420 vec![make_dep_marker(
421 "src/a.rs",
422 "fn_a",
423 "src/shared.rs",
424 "shared_fn",
425 "new nature",
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 annotated_commits: vec!["commit2".to_string(), "commit1".to_string()],
436 notes,
437 };
438
439 let query = DepsQuery {
440 file: "src/shared.rs".to_string(),
441 anchor: Some("shared_fn".to_string()),
442 max_results: 50,
443 scan_limit: 500,
444 };
445
446 let result = find_dependents(&git, &query).unwrap();
447 assert_eq!(result.dependents.len(), 1);
448 assert_eq!(result.dependents[0].nature, "new nature");
450 }
451
452 #[test]
453 fn test_scan_limit_respected() {
454 let note = make_v2_annotation(
455 "commit1",
456 "2025-01-01T00:00:00Z",
457 vec![make_dep_marker(
458 "src/a.rs",
459 "fn_a",
460 "src/shared.rs",
461 "shared_fn",
462 "test",
463 )],
464 );
465
466 let mut notes = std::collections::HashMap::new();
467 notes.insert("commit1".to_string(), note);
468
469 let git = MockGitOps {
470 annotated_commits: vec!["commit1".to_string()],
471 notes,
472 };
473
474 let query = DepsQuery {
476 file: "src/shared.rs".to_string(),
477 anchor: Some("shared_fn".to_string()),
478 max_results: 50,
479 scan_limit: 0,
480 };
481
482 let result = find_dependents(&git, &query).unwrap();
483 assert_eq!(result.dependents.len(), 0);
484 assert_eq!(result.stats.commits_scanned, 0);
485 }
486
487 #[test]
488 fn test_max_results_cap() {
489 let note = make_v2_annotation(
490 "commit1",
491 "2025-01-01T00:00:00Z",
492 vec![
493 make_dep_marker("src/a.rs", "fn_a", "src/shared.rs", "shared_fn", "dep 1"),
494 make_dep_marker("src/b.rs", "fn_b", "src/shared.rs", "shared_fn", "dep 2"),
495 make_dep_marker("src/c.rs", "fn_c", "src/shared.rs", "shared_fn", "dep 3"),
496 ],
497 );
498
499 let mut notes = std::collections::HashMap::new();
500 notes.insert("commit1".to_string(), note);
501
502 let git = MockGitOps {
503 annotated_commits: vec!["commit1".to_string()],
504 notes,
505 };
506
507 let query = DepsQuery {
508 file: "src/shared.rs".to_string(),
509 anchor: Some("shared_fn".to_string()),
510 max_results: 2,
511 scan_limit: 500,
512 };
513
514 let result = find_dependents(&git, &query).unwrap();
515 assert_eq!(result.dependents.len(), 2);
516 }
517
518 #[test]
519 fn test_file_only_query() {
520 let note = make_v2_annotation(
521 "commit1",
522 "2025-01-01T00:00:00Z",
523 vec![make_dep_marker(
524 "src/mqtt/reconnect.rs",
525 "ReconnectHandler::attempt",
526 "src/tls/session.rs",
527 "TlsSessionCache::max_sessions",
528 "assumes max_sessions is 4",
529 )],
530 );
531
532 let mut notes = std::collections::HashMap::new();
533 notes.insert("commit1".to_string(), note);
534
535 let git = MockGitOps {
536 annotated_commits: vec!["commit1".to_string()],
537 notes,
538 };
539
540 let query = DepsQuery {
542 file: "src/tls/session.rs".to_string(),
543 anchor: None,
544 max_results: 50,
545 scan_limit: 500,
546 };
547
548 let result = find_dependents(&git, &query).unwrap();
549 assert_eq!(result.dependents.len(), 1);
550 }
551}