Skip to main content

chronicle/read/
deps.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
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 markers whose `Dependency` kind
52/// references 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 = match schema::parse_annotation(&note) {
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: keep most recent entry per (file, anchor) pair
105    deduplicate(&mut dependents);
106
107    // Apply max_results cap
108    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
127/// Check if a dependency marker's target matches the queried file+anchor.
128/// Supports unqualified matching: "max_sessions" matches "TlsSessionCache::max_sessions".
129fn 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
146/// Deduplicate dependents by (file, anchor), keeping the first occurrence
147/// (which is the most recent since we scan newest-first from list_annotated_commits).
148fn 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            // newest first
435            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        // Should keep the first (most recent) one
449        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        // scan_limit=0 means we scan nothing
475        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        // No anchor specified — should match any dep referencing the file
541        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}