tldr_cli/commands/bugbot/
changes.rs1use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result};
10
11use tldr_core::Language;
12
13#[derive(Debug, Clone)]
15pub struct ChangeDetectionResult {
16 pub changed_files: Vec<PathBuf>,
18 pub detection_method: String,
20}
21
22fn git_changed_files(project: &Path, args: &[&str]) -> Result<Vec<PathBuf>> {
26 let output = Command::new("git")
27 .args(args)
28 .current_dir(project)
29 .output()
30 .context("Failed to run git")?;
31
32 if !output.status.success() {
33 let stderr = String::from_utf8_lossy(&output.stderr);
34 anyhow::bail!("git command failed: {}", stderr);
35 }
36
37 let stdout = String::from_utf8_lossy(&output.stdout);
38 Ok(stdout
39 .lines()
40 .filter(|l| !l.is_empty())
41 .map(|l| project.join(l))
42 .collect())
43}
44
45pub fn detect_changes(
61 project: &Path,
62 base_ref: &str,
63 staged: bool,
64 language: &Language,
65) -> Result<ChangeDetectionResult> {
66 let (raw_files, detection_method) = if staged {
67 let files = git_changed_files(project, &["diff", "--name-only", "--staged"])
68 .context("Failed to list staged changes")?;
69 (files, "git:staged".to_string())
70 } else if base_ref == "HEAD" {
71 let mut files = git_changed_files(project, &["diff", "--name-only", "HEAD"])
73 .context("Failed to list uncommitted changes")?;
74 let staged_files = git_changed_files(project, &["diff", "--name-only", "--staged"])
75 .context("Failed to list staged changes")?;
76 let untracked = git_changed_files(project, &["ls-files", "--others", "--exclude-standard"])
77 .context("Failed to list untracked files")?;
78 files.extend(staged_files);
79 files.extend(untracked);
80 files.sort();
81 files.dedup();
82 (files, "git:uncommitted".to_string())
83 } else {
84 let range = format!("{}...HEAD", base_ref);
85 let files = git_changed_files(project, &["diff", "--name-only", &range])
86 .context("Failed to list base-ref changes")?;
87 (files, format!("git:{}...HEAD", base_ref))
88 };
89
90 let valid_extensions = language.extensions();
92 let changed_files: Vec<PathBuf> = raw_files
93 .into_iter()
94 .filter(|f| {
95 f.extension()
96 .and_then(|e| e.to_str())
97 .map(|ext| {
98 let dotted = format!(".{}", ext);
99 valid_extensions.contains(&dotted.as_str())
100 })
101 .unwrap_or(false)
102 })
103 .collect();
104
105 let changed_files = tldr_core::callgraph::filter_tldrignored(project, changed_files);
107
108 Ok(ChangeDetectionResult {
109 changed_files,
110 detection_method,
111 })
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use tempfile::TempDir;
118
119 fn init_git_repo() -> TempDir {
121 let tmp = TempDir::new().expect("create temp dir");
122 let dir = tmp.path();
123
124 Command::new("git")
125 .args(["init"])
126 .current_dir(dir)
127 .output()
128 .expect("git init");
129
130 Command::new("git")
131 .args(["config", "user.email", "test@test.com"])
132 .current_dir(dir)
133 .output()
134 .expect("git config email");
135
136 Command::new("git")
137 .args(["config", "user.name", "Test"])
138 .current_dir(dir)
139 .output()
140 .expect("git config name");
141
142 std::fs::write(dir.join("README.md"), "# test\n").expect("write readme");
144 Command::new("git")
145 .args(["add", "."])
146 .current_dir(dir)
147 .output()
148 .expect("git add");
149 Command::new("git")
150 .args(["commit", "-m", "init"])
151 .current_dir(dir)
152 .output()
153 .expect("git commit");
154
155 tmp
156 }
157
158 #[test]
159 fn test_detect_changes_no_changes_returns_empty() {
160 let tmp = init_git_repo();
161 let result =
162 detect_changes(tmp.path(), "HEAD", false, &Language::Rust).expect("detect_changes");
163
164 assert!(
165 result.changed_files.is_empty(),
166 "Expected no changed files in a clean repo, got: {:?}",
167 result.changed_files
168 );
169 assert_eq!(result.detection_method, "git:uncommitted");
170 }
171
172 #[test]
173 fn test_detect_changes_staged_method() {
174 let tmp = init_git_repo();
175 let result =
176 detect_changes(tmp.path(), "HEAD", true, &Language::Rust).expect("detect_changes");
177
178 assert_eq!(result.detection_method, "git:staged");
179 }
180
181 #[test]
182 fn test_detect_changes_base_ref_method() {
183 let tmp = init_git_repo();
184 Command::new("git")
186 .args(["branch", "main"])
187 .current_dir(tmp.path())
188 .output()
189 .expect("git branch main");
190
191 let result =
192 detect_changes(tmp.path(), "main", false, &Language::Python).expect("detect_changes");
193
194 assert_eq!(result.detection_method, "git:main...HEAD");
195 }
196
197 #[test]
198 fn test_detect_changes_filters_by_language() {
199 let tmp = init_git_repo();
200 let dir = tmp.path();
201
202 std::fs::write(dir.join("hello.rs"), "fn main() {}\n").expect("write rs");
204 std::fs::write(dir.join("hello.py"), "print('hi')\n").expect("write py");
205 std::fs::write(dir.join("hello.js"), "console.log('hi')\n").expect("write js");
206
207 Command::new("git")
209 .args(["add", "."])
210 .current_dir(dir)
211 .output()
212 .expect("git add");
213
214 let result =
216 detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes rust");
217
218 for f in &result.changed_files {
220 assert_eq!(
221 f.extension().and_then(|e| e.to_str()),
222 Some("rs"),
223 "Expected only .rs files, got: {}",
224 f.display()
225 );
226 }
227 assert!(
228 !result.changed_files.is_empty(),
229 "Expected at least one .rs file in changed_files"
230 );
231
232 let result =
234 detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes python");
235
236 for f in &result.changed_files {
237 assert_eq!(
238 f.extension().and_then(|e| e.to_str()),
239 Some("py"),
240 "Expected only .py files, got: {}",
241 f.display()
242 );
243 }
244 assert!(
245 !result.changed_files.is_empty(),
246 "Expected at least one .py file in changed_files"
247 );
248 }
249
250 #[test]
251 fn test_detect_changes_uncommitted_finds_unstaged() {
252 let tmp = init_git_repo();
253 let dir = tmp.path();
254
255 let rs_file = dir.join("lib.rs");
257 std::fs::write(&rs_file, "pub fn old() {}\n").expect("write rs");
258 Command::new("git")
259 .args(["add", "lib.rs"])
260 .current_dir(dir)
261 .output()
262 .expect("git add");
263 Command::new("git")
264 .args(["commit", "-m", "add lib"])
265 .current_dir(dir)
266 .output()
267 .expect("git commit");
268
269 std::fs::write(&rs_file, "pub fn new_version() {}\n").expect("overwrite rs");
271
272 let result = detect_changes(dir, "HEAD", false, &Language::Rust).expect("detect_changes");
273
274 assert_eq!(result.detection_method, "git:uncommitted");
275 assert!(
276 result.changed_files.iter().any(|f| {
277 f.file_name()
278 .and_then(|n| n.to_str())
279 .map(|n| n == "lib.rs")
280 .unwrap_or(false)
281 }),
282 "Expected lib.rs in changed files, got: {:?}",
283 result.changed_files
284 );
285 }
286
287 #[test]
288 fn test_detect_changes_ignores_non_matching_extensions() {
289 let tmp = init_git_repo();
290 let dir = tmp.path();
291
292 std::fs::write(dir.join("app.py"), "x = 1\n").expect("write py");
294 std::fs::write(dir.join("app.js"), "var x = 1;\n").expect("write js");
295 Command::new("git")
296 .args(["add", "."])
297 .current_dir(dir)
298 .output()
299 .expect("git add");
300
301 let result = detect_changes(dir, "HEAD", true, &Language::Rust).expect("detect_changes");
302
303 assert!(
304 result.changed_files.is_empty(),
305 "Expected no Rust files when only .py and .js were changed, got: {:?}",
306 result.changed_files
307 );
308 }
309
310 #[test]
311 fn test_change_detection_result_fields() {
312 let result = ChangeDetectionResult {
313 changed_files: vec![PathBuf::from("src/main.rs")],
314 detection_method: "git:staged".to_string(),
315 };
316 assert_eq!(result.changed_files.len(), 1);
317 assert_eq!(result.detection_method, "git:staged");
318 }
319
320 #[test]
321 fn test_detect_changes_respects_tldrignore() {
322 let tmp = init_git_repo();
323 let dir = tmp.path();
324
325 std::fs::create_dir_all(dir.join("corpus")).unwrap();
327 std::fs::create_dir_all(dir.join("src")).unwrap();
328 std::fs::write(dir.join("corpus/vendored.py"), "x = 1\n").unwrap();
329 std::fs::write(dir.join("src/main.py"), "y = 2\n").unwrap();
330
331 std::fs::write(dir.join(".tldrignore"), "corpus/\n").unwrap();
333
334 Command::new("git")
336 .args(["add", "."])
337 .current_dir(dir)
338 .output()
339 .expect("git add");
340
341 let result = detect_changes(dir, "HEAD", true, &Language::Python).expect("detect_changes");
342
343 assert!(
345 !result
346 .changed_files
347 .iter()
348 .any(|f| { f.to_string_lossy().contains("corpus") }),
349 "corpus/ files should be excluded by .tldrignore, got: {:?}",
350 result.changed_files
351 );
352 assert!(
353 result.changed_files.iter().any(|f| {
354 f.file_name()
355 .and_then(|n| n.to_str())
356 .map(|n| n == "main.py")
357 .unwrap_or(false)
358 }),
359 "src/main.py should be present, got: {:?}",
360 result.changed_files
361 );
362 }
363}