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