Skip to main content

kardo_core/git/
coupling.rs

1//! Code-documentation coupling detection — finds docs that lag behind their referenced source files.
2
3use super::analysis::GitFileInfo;
4use std::collections::HashMap;
5
6/// A detected case where source code changed more recently than its documentation.
7#[derive(Debug, Clone)]
8pub struct CouplingIssue {
9    pub source_path: String,
10    pub doc_path: String,
11    pub source_last_modified: String,
12    pub doc_last_modified: String,
13    pub gap_days: i64,
14}
15
16/// Detects code-documentation coupling drift from git history.
17pub struct CouplingDetector;
18
19impl CouplingDetector {
20    /// Detect code-doc coupling issues.
21    ///
22    /// Given a list of doc files and their referenced code paths,
23    /// check if any code file changed more recently than the doc.
24    ///
25    /// `doc_files`: slice of (doc_path, referenced_code_paths)
26    /// `git_infos`: git metadata for all relevant files
27    pub fn detect(
28        doc_files: &[(String, Vec<String>)],
29        git_infos: &[GitFileInfo],
30    ) -> Vec<CouplingIssue> {
31        let info_map: HashMap<&str, &GitFileInfo> = git_infos
32            .iter()
33            .map(|info| (info.relative_path.as_str(), info))
34            .collect();
35
36        let mut issues = Vec::new();
37
38        for (doc_path, code_paths) in doc_files {
39            let doc_info = match info_map.get(doc_path.as_str()) {
40                Some(info) => info,
41                None => continue,
42            };
43
44            let doc_modified = match &doc_info.last_modified {
45                Some(dt) => dt,
46                None => continue,
47            };
48
49            let doc_days = match doc_info.days_since_modified {
50                Some(d) => d,
51                None => continue,
52            };
53
54            for code_path in code_paths {
55                let code_info = match info_map.get(code_path.as_str()) {
56                    Some(info) => info,
57                    None => continue,
58                };
59
60                let code_modified = match &code_info.last_modified {
61                    Some(dt) => dt,
62                    None => continue,
63                };
64
65                let code_days = match code_info.days_since_modified {
66                    Some(d) => d,
67                    None => continue,
68                };
69
70                // Code was modified more recently than doc (fewer days since modified)
71                if code_days < doc_days {
72                    let gap = doc_days - code_days;
73                    issues.push(CouplingIssue {
74                        source_path: code_path.clone(),
75                        doc_path: doc_path.clone(),
76                        source_last_modified: code_modified.clone(),
77                        doc_last_modified: doc_modified.clone(),
78                        gap_days: gap,
79                    });
80                }
81            }
82        }
83
84        issues
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::git::analysis::GitAnalyzer;
92    use git2::{Repository, Signature};
93    use std::fs;
94    use std::path::Path;
95    use tempfile::TempDir;
96
97    fn commit_file(repo: &Repository, path: &str, content: &str, message: &str) {
98        let root = repo.workdir().unwrap();
99        let file_path = root.join(path);
100        if let Some(parent) = file_path.parent() {
101            fs::create_dir_all(parent).unwrap();
102        }
103        fs::write(&file_path, content).unwrap();
104
105        let mut index = repo.index().unwrap();
106        index.add_path(Path::new(path)).unwrap();
107        index.write().unwrap();
108
109        let tree_id = index.write_tree().unwrap();
110        let tree = repo.find_tree(tree_id).unwrap();
111        let sig = Signature::now("Test", "test@test.com").unwrap();
112
113        let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
114        let parents: Vec<&git2::Commit> = parent.iter().collect();
115
116        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
117            .unwrap();
118    }
119
120    fn commit_file_with_time(
121        repo: &Repository,
122        path: &str,
123        content: &str,
124        message: &str,
125        epoch_secs: i64,
126    ) {
127        let root = repo.workdir().unwrap();
128        let file_path = root.join(path);
129        if let Some(parent) = file_path.parent() {
130            fs::create_dir_all(parent).unwrap();
131        }
132        fs::write(&file_path, content).unwrap();
133
134        let mut index = repo.index().unwrap();
135        index.add_path(Path::new(path)).unwrap();
136        index.write().unwrap();
137
138        let tree_id = index.write_tree().unwrap();
139        let tree = repo.find_tree(tree_id).unwrap();
140        let time = git2::Time::new(epoch_secs, 0);
141        let sig = Signature::new("Test", "test@test.com", &time).unwrap();
142
143        let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
144        let parents: Vec<&git2::Commit> = parent.iter().collect();
145
146        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
147            .unwrap();
148    }
149
150    #[test]
151    fn test_coupling_detection() {
152        let dir = TempDir::new().unwrap();
153        let repo = Repository::init(dir.path()).unwrap();
154
155        // Doc committed 30 days ago
156        let thirty_days_ago = chrono::Utc::now().timestamp() - 30 * 86400;
157        commit_file_with_time(
158            &repo,
159            "docs/api.md",
160            "# API docs",
161            "Add docs",
162            thirty_days_ago,
163        );
164
165        // Code committed now (after the doc)
166        commit_file(&repo, "src/api.rs", "fn api() {}", "Update api code");
167
168        let analyzer = GitAnalyzer::open(dir.path()).unwrap();
169        let infos = analyzer
170            .analyze_files(&[
171                "docs/api.md".to_string(),
172                "src/api.rs".to_string(),
173            ])
174            .unwrap();
175
176        let doc_files = vec![(
177            "docs/api.md".to_string(),
178            vec!["src/api.rs".to_string()],
179        )];
180
181        let issues = CouplingDetector::detect(&doc_files, &infos);
182        assert_eq!(issues.len(), 1);
183        assert_eq!(issues[0].source_path, "src/api.rs");
184        assert_eq!(issues[0].doc_path, "docs/api.md");
185        assert!(issues[0].gap_days > 0);
186    }
187
188    #[test]
189    fn test_no_coupling_issue() {
190        let dir = TempDir::new().unwrap();
191        let repo = Repository::init(dir.path()).unwrap();
192
193        // Code committed 30 days ago
194        let thirty_days_ago = chrono::Utc::now().timestamp() - 30 * 86400;
195        commit_file_with_time(
196            &repo,
197            "src/api.rs",
198            "fn api() {}",
199            "Add api code",
200            thirty_days_ago,
201        );
202
203        // Doc committed now (after the code) — doc is up to date
204        commit_file(&repo, "docs/api.md", "# Updated API docs", "Update docs");
205
206        let analyzer = GitAnalyzer::open(dir.path()).unwrap();
207        let infos = analyzer
208            .analyze_files(&[
209                "docs/api.md".to_string(),
210                "src/api.rs".to_string(),
211            ])
212            .unwrap();
213
214        let doc_files = vec![(
215            "docs/api.md".to_string(),
216            vec!["src/api.rs".to_string()],
217        )];
218
219        let issues = CouplingDetector::detect(&doc_files, &infos);
220        assert!(issues.is_empty(), "Expected no coupling issues");
221    }
222}