Skip to main content

chronicle/read/
deps.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::annotation::Annotation;
4
5/// Query parameters for dependency inversion.
6#[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/// A single dependent found during the scan.
15#[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/// Statistics about the deps scan.
26#[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/// Output of a deps query.
34#[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/// Echo of the query parameters in the output.
43#[derive(Debug, Clone, serde::Serialize)]
44pub struct QueryEcho {
45    pub file: String,
46    pub anchor: Option<String>,
47}
48
49/// Execute a dependency inversion query via linear scan.
50///
51/// Scans annotated commits and finds regions whose `semantic_dependencies`
52/// reference the queried file+anchor.
53pub 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(&note) {
66            Ok(a) => a,
67            Err(_) => continue,
68        };
69
70        for region in &annotation.regions {
71            for dep in &region.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: keep most recent entry per (file, anchor) pair
87    deduplicate(&mut dependents);
88
89    // Apply max_results cap
90    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
109/// Check if a semantic dependency matches the queried file+anchor.
110/// Supports unqualified matching: "max_sessions" matches "TlsSessionCache::max_sessions".
111fn 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
132/// Check if a dependency anchor matches the query anchor.
133/// Supports unqualified matching: "max_sessions" matches "TlsSessionCache::max_sessions"
134/// and vice versa.
135fn anchor_matches(dep_anchor: &str, query_anchor: &str) -> bool {
136    if dep_anchor == query_anchor {
137        return true;
138    }
139    // Unqualified match: check if one is a suffix of the other after "::"
140    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
145/// Deduplicate dependents by (file, anchor), keeping the first occurrence
146/// (which is the most recent since we scan newest-first from list_annotated_commits).
147fn 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        // Two commits that both show src/a.rs:fn_a depending on src/shared.rs:shared_fn
426        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            // newest first
459            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        // Should keep the first (most recent) one
473        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        // scan_limit=0 means we scan nothing
501        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        // No anchor specified — should match any dep referencing the file
596        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}