kardo_core/git/
coupling.rs1use super::analysis::GitFileInfo;
4use std::collections::HashMap;
5
6#[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
16pub struct CouplingDetector;
18
19impl CouplingDetector {
20 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 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 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 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 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 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}