Skip to main content

chronicle/read/
deps.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
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/// In v3, dependency information lives in `insight` wisdom entries
52/// with the pattern "Depends on {file}:{anchor} — {assumption}".
53/// This scans annotated commits for wisdom entries referencing the queried file+anchor.
54pub 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(&note) {
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            // Parse "Depends on {target_file}:{target_anchor} — {assumption}"
80            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: keep most recent entry per (file, anchor) pair
97    deduplicate(&mut dependents);
98
99    // Apply max_results cap
100    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
119/// Parse dependency content from the migration format:
120/// "Depends on {file}:{anchor} — {assumption}"
121fn 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
128/// Check if a dependency's target matches the queried file+anchor.
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    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, ""); // v3 reader sets anchor to empty string
301        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            // newest first
437            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        // Should keep the first (most recent) one
451        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        // scan_limit=0 means we scan nothing
477        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        // No anchor specified — should match any dep referencing the file
543        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}