1use std::path::Path;
4
5#[derive(Debug, PartialEq, Eq)]
7pub enum DiffArg {
8 Snapshot(std::path::PathBuf),
10 GitRef(String),
12}
13
14pub fn classify_diff_arg(arg: &str, repo_root: &Path) -> Result<DiffArg, crate::error::Error> {
22 let path = Path::new(arg);
23
24 if path.is_file() {
26 return Ok(DiffArg::Snapshot(path.to_path_buf()));
27 }
28
29 let output = std::process::Command::new("git")
31 .args(["rev-parse", "--verify", &format!("{arg}^{{commit}}")])
32 .current_dir(repo_root)
33 .output()
34 .map_err(|e| crate::error::Error::GitError(format!("failed to run git: {e}")))?;
35
36 if output.status.success() {
37 return Ok(DiffArg::GitRef(arg.to_string()));
38 }
39
40 let looks_like_path = arg.contains('/')
42 || arg.contains(std::path::MAIN_SEPARATOR)
43 || path
44 .extension()
45 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
46 if looks_like_path {
47 return Err(crate::error::Error::DiffFileNotFound(arg.to_string()));
48 }
49
50 Err(crate::error::Error::NotSnapshotOrRef(arg.to_string()))
52}
53
54pub fn is_git_repo(path: &Path) -> bool {
56 std::process::Command::new("git")
57 .args(["rev-parse", "--git-dir"])
58 .current_dir(path)
59 .output()
60 .is_ok_and(|o| o.status.success())
61}
62
63pub fn repo_root(path: &Path) -> Result<std::path::PathBuf, crate::error::Error> {
65 let output = std::process::Command::new("git")
66 .args(["rev-parse", "--show-toplevel"])
67 .current_dir(path)
68 .output()
69 .map_err(|e| crate::error::Error::GitError(format!("failed to run git: {e}")))?;
70
71 if !output.status.success() {
72 return Err(crate::error::Error::NotAGitRepo);
73 }
74
75 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
76 Ok(std::path::PathBuf::from(root))
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use std::process::Command;
83
84 fn git_repo() -> (tempfile::TempDir, String) {
86 let tmp = tempfile::tempdir().unwrap();
87 let dir = tmp.path();
88 Command::new("git")
89 .args(["init"])
90 .current_dir(dir)
91 .output()
92 .unwrap();
93 Command::new("git")
94 .args(["config", "user.email", "test@test.com"])
95 .current_dir(dir)
96 .output()
97 .unwrap();
98 Command::new("git")
99 .args(["config", "user.name", "Test"])
100 .current_dir(dir)
101 .output()
102 .unwrap();
103 std::fs::write(dir.join("file.txt"), "hello").unwrap();
104 Command::new("git")
105 .args(["add", "."])
106 .current_dir(dir)
107 .output()
108 .unwrap();
109 Command::new("git")
110 .args(["commit", "-m", "init"])
111 .current_dir(dir)
112 .output()
113 .unwrap();
114 let sha = String::from_utf8(
115 Command::new("git")
116 .args(["rev-parse", "HEAD"])
117 .current_dir(dir)
118 .output()
119 .unwrap()
120 .stdout,
121 )
122 .unwrap()
123 .trim()
124 .to_string();
125 (tmp, sha)
126 }
127
128 #[test]
129 fn existing_file_is_snapshot() {
130 let (tmp, _) = git_repo();
131 let snap = tmp.path().join("snap.json");
132 std::fs::write(&snap, "{}").unwrap();
133 let result = classify_diff_arg(snap.to_str().unwrap(), tmp.path());
134 assert_eq!(result.unwrap(), DiffArg::Snapshot(snap));
135 }
136
137 #[test]
138 fn branch_name_is_git_ref() {
139 let (tmp, _) = git_repo();
140 let branch = String::from_utf8(
142 Command::new("git")
143 .args(["branch", "--show-current"])
144 .current_dir(tmp.path())
145 .output()
146 .unwrap()
147 .stdout,
148 )
149 .unwrap()
150 .trim()
151 .to_string();
152 let result = classify_diff_arg(&branch, tmp.path());
153 assert!(matches!(result, Ok(DiffArg::GitRef(_))));
154 }
155
156 #[test]
157 fn sha_is_git_ref() {
158 let (tmp, sha) = git_repo();
159 let result = classify_diff_arg(&sha, tmp.path());
160 assert_eq!(result.unwrap(), DiffArg::GitRef(sha));
161 }
162
163 #[test]
164 fn short_sha_is_git_ref() {
165 let (tmp, sha) = git_repo();
166 let short = &sha[..7];
167 let result = classify_diff_arg(short, tmp.path());
168 assert!(matches!(result, Ok(DiffArg::GitRef(_))));
169 }
170
171 #[test]
172 fn head_tilde_is_git_ref() {
173 let (tmp, _) = git_repo();
174 let result = classify_diff_arg("HEAD~0", tmp.path());
175 assert!(matches!(result, Ok(DiffArg::GitRef(_))));
176 }
177
178 #[test]
179 fn path_like_nonexistent_is_file_not_found() {
180 let (tmp, _) = git_repo();
181 let result = classify_diff_arg("./missing.json", tmp.path());
182 assert!(result.is_err());
183 let err = result.unwrap_err().to_string();
184 assert!(err.contains("file not found"), "got: {err}");
185 }
186
187 #[test]
188 fn json_extension_nonexistent_is_file_not_found() {
189 let (tmp, _) = git_repo();
190 let result = classify_diff_arg("missing.json", tmp.path());
191 assert!(result.is_err());
192 let err = result.unwrap_err().to_string();
193 assert!(err.contains("file not found"), "got: {err}");
194 }
195
196 #[test]
197 fn path_with_slash_nonexistent_is_file_not_found() {
198 let (tmp, _) = git_repo();
199 let result = classify_diff_arg("some/path/file", tmp.path());
200 assert!(result.is_err());
201 let err = result.unwrap_err().to_string();
202 assert!(err.contains("file not found"), "got: {err}");
203 }
204
205 #[test]
206 fn nonsense_is_not_snapshot_or_ref() {
207 let (tmp, _) = git_repo();
208 let result = classify_diff_arg("xyzzy-not-a-ref", tmp.path());
209 assert!(result.is_err());
210 let err = result.unwrap_err().to_string();
211 assert!(
212 err.contains("not a snapshot file or a valid git ref"),
213 "got: {err}"
214 );
215 }
216
217 #[test]
218 fn file_named_main_beats_branch() {
219 let (tmp, _) = git_repo();
220 let main_file = tmp.path().join("main");
221 std::fs::write(&main_file, "{}").unwrap();
222 let result = classify_diff_arg(main_file.to_str().unwrap(), tmp.path());
224 assert!(matches!(result, Ok(DiffArg::Snapshot(_))));
225 }
226
227 #[test]
228 fn tag_is_git_ref() {
229 let (tmp, _) = git_repo();
230 Command::new("git")
231 .args(["tag", "v1.0.0"])
232 .current_dir(tmp.path())
233 .output()
234 .unwrap();
235 let result = classify_diff_arg("v1.0.0", tmp.path());
236 assert_eq!(result.unwrap(), DiffArg::GitRef("v1.0.0".to_string()));
237 }
238
239 #[test]
240 fn annotated_tag_is_git_ref() {
241 let (tmp, _) = git_repo();
242 Command::new("git")
243 .args(["tag", "-a", "v2.0.0", "-m", "release"])
244 .current_dir(tmp.path())
245 .output()
246 .unwrap();
247 let result = classify_diff_arg("v2.0.0", tmp.path());
248 assert_eq!(result.unwrap(), DiffArg::GitRef("v2.0.0".to_string()));
249 }
250
251 #[test]
252 fn branch_with_slash_is_git_ref() {
253 let (tmp, _) = git_repo();
254 Command::new("git")
255 .args(["branch", "feature/auth"])
256 .current_dir(tmp.path())
257 .output()
258 .unwrap();
259 let result = classify_diff_arg("feature/auth", tmp.path());
260 assert!(matches!(result, Ok(DiffArg::GitRef(_))));
261 }
262
263 #[test]
264 fn nonexistent_path_with_slash_still_errors() {
265 let (tmp, _) = git_repo();
266 let result = classify_diff_arg("some/nonexistent/path", tmp.path());
267 assert!(result.is_err());
268 let err = result.unwrap_err().to_string();
269 assert!(err.contains("file not found"), "got: {err}");
270 }
271
272 #[test]
273 fn integration_diff_two_refs() {
274 use std::sync::Arc;
275
276 let tmp = tempfile::tempdir().unwrap();
277 let dir = tmp.path();
278
279 Command::new("git")
281 .args(["init"])
282 .current_dir(dir)
283 .output()
284 .unwrap();
285 Command::new("git")
286 .args(["config", "user.email", "t@t.com"])
287 .current_dir(dir)
288 .output()
289 .unwrap();
290 Command::new("git")
291 .args(["config", "user.name", "T"])
292 .current_dir(dir)
293 .output()
294 .unwrap();
295
296 std::fs::write(dir.join("index.ts"), "import './a';\n").unwrap();
298 std::fs::write(dir.join("a.ts"), "export const a = 1;\n").unwrap();
299 Command::new("git")
300 .args(["add", "."])
301 .current_dir(dir)
302 .output()
303 .unwrap();
304 Command::new("git")
305 .args(["commit", "-m", "v1"])
306 .current_dir(dir)
307 .output()
308 .unwrap();
309 let sha1 = String::from_utf8(
310 Command::new("git")
311 .args(["rev-parse", "HEAD"])
312 .current_dir(dir)
313 .output()
314 .unwrap()
315 .stdout,
316 )
317 .unwrap()
318 .trim()
319 .to_string();
320
321 std::fs::write(dir.join("index.ts"), "import './a';\nimport './b';\n").unwrap();
323 std::fs::write(
324 dir.join("b.ts"),
325 "export const b = 'hello world this is extra weight';\n",
326 )
327 .unwrap();
328 Command::new("git")
329 .args(["add", "."])
330 .current_dir(dir)
331 .output()
332 .unwrap();
333 Command::new("git")
334 .args(["commit", "-m", "v2"])
335 .current_dir(dir)
336 .output()
337 .unwrap();
338 let sha2 = String::from_utf8(
339 Command::new("git")
340 .args(["rev-parse", "HEAD"])
341 .current_dir(dir)
342 .output()
343 .unwrap()
344 .stdout,
345 )
346 .unwrap()
347 .trim()
348 .to_string();
349
350 let entry = std::path::Path::new("index.ts");
352 let opts = crate::query::TraceOptions {
353 include_dynamic: false,
354 top_n: 0,
355 ignore: vec![],
356 };
357
358 let vfs1 = Arc::new(crate::vfs::GitTreeVfs::new(dir, &sha1, dir).unwrap());
359 let (loaded1, _cw1) =
360 crate::loader::load_graph_with_vfs(&dir.join(entry), true, vfs1).unwrap();
361 let eid1 = *loaded1.graph.path_to_id.get(&loaded1.entry).unwrap();
362 let snap1 = crate::query::trace(&loaded1.graph, eid1, &opts).to_snapshot("v1");
363
364 let vfs2 = Arc::new(crate::vfs::GitTreeVfs::new(dir, &sha2, dir).unwrap());
365 let (loaded2, _cw2) =
366 crate::loader::load_graph_with_vfs(&dir.join(entry), true, vfs2).unwrap();
367 let eid2 = *loaded2.graph.path_to_id.get(&loaded2.entry).unwrap();
368 let snap2 = crate::query::trace(&loaded2.graph, eid2, &opts).to_snapshot("v2");
369
370 let diff = crate::query::diff_snapshots(&snap1, &snap2);
372 assert!(
373 diff.weight_delta > 0,
374 "v2 should be heavier than v1, delta={}",
375 diff.weight_delta
376 );
377 }
378}