1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use crate::paths::{DvsPaths, PathFilter};
5use anyhow::{Result, anyhow, bail};
6use globset::{GlobBuilder, GlobMatcher};
7use walkdir::WalkDir;
8
9pub(crate) fn build_glob_matcher(pattern: Option<&str>) -> Result<Option<GlobMatcher>> {
12 pattern
13 .map(|p| {
14 GlobBuilder::new(p)
15 .literal_separator(true)
16 .build()
17 .map(|g| g.compile_matcher())
18 .map_err(Into::into)
19 })
20 .transpose()
21}
22
23pub fn resolve_paths_for_add(
28 paths: Vec<PathBuf>,
29 glob_pattern: Option<&str>,
30 dvs_paths: &DvsPaths,
31) -> Result<HashSet<PathBuf>> {
32 let mut out = HashSet::new();
33 let glob_matcher = build_glob_matcher(glob_pattern)?;
34 let repo_root = dvs_paths.repo_root().canonicalize()?;
35 let metadata_root = dvs_paths.metadata_folder().canonicalize()?;
36
37 let paths = if paths.is_empty() {
39 vec![PathBuf::from(".")]
40 } else {
41 paths
42 };
43
44 for path in paths {
45 let full_path = dvs_paths
46 .cwd()
47 .join(&path)
48 .canonicalize()
49 .map_err(|_| anyhow!("Path not found: {}", path.display()))?;
50
51 if full_path.is_file() {
53 let relative_to_root = match full_path.strip_prefix(&repo_root) {
54 Ok(p) => p.to_path_buf(),
55 Err(_) => path.clone(),
57 };
58 out.insert(relative_to_root);
59 } else if full_path.is_dir() {
60 if !full_path.starts_with(&repo_root) {
61 out.insert(path.clone());
63 } else if let Some(matcher) = &glob_matcher {
64 for entry in WalkDir::new(&full_path).into_iter().filter_map(|e| e.ok()) {
65 let entry_path = entry.path().canonicalize()?;
66 if !entry_path.is_file() || entry_path.starts_with(&metadata_root) {
68 continue;
69 }
70
71 let relative_to_dir = match entry_path.strip_prefix(&full_path) {
73 Ok(p) => p,
74 Err(_) => {
75 if !entry_path.starts_with(&repo_root) {
77 log::warn!(
78 "Skipping {}: symlink resolves outside the project root ({})",
79 entry.path().display(),
80 entry_path.display()
81 );
82 }
83 continue;
84 }
85 };
86 if matcher.is_match(relative_to_dir) {
87 let relative_to_root = match entry_path.strip_prefix(&repo_root) {
89 Ok(p) => p.to_path_buf(),
90 Err(_) => continue,
91 };
92 out.insert(relative_to_root);
93 }
94 }
95 } else {
96 let relative_to_root = match full_path.strip_prefix(&repo_root) {
97 Ok(p) => p.to_path_buf(),
98 Err(_) => path.clone(),
99 };
100 out.insert(relative_to_root);
101 }
102 } else {
103 bail!("Path is not a file or directory: {}", path.display());
104 }
105 }
106
107 Ok(out)
108}
109
110pub fn resolve_paths_for_get(
115 paths: Vec<PathBuf>,
116 glob_pattern: Option<&str>,
117 dvs_paths: &DvsPaths,
118 recursive: bool,
119) -> Result<HashSet<PathBuf>> {
120 let glob_matcher = build_glob_matcher(glob_pattern)?;
121 let tracked = dvs_paths.tracked_paths();
122
123 if paths.is_empty() {
125 let filter = PathFilter::cwd_scoped(recursive, dvs_paths);
126 return Ok(tracked
127 .into_iter()
128 .filter(|t| filter.matches(t, glob_matcher.as_ref()))
129 .collect());
130 }
131
132 let mut out = HashSet::new();
135 let mut missing = Vec::new();
136 for path in paths {
137 let filter = PathFilter::from_user_paths(vec![path.clone()], recursive, dvs_paths);
138 let mut matched_any = false;
139 for tracked_path in &tracked {
140 if filter.matches(tracked_path, glob_matcher.as_ref()) {
141 out.insert(tracked_path.clone());
142 matched_any = true;
143 }
144 }
145 if !matched_any {
146 missing.push(format!(" {}", path.display()));
147 }
148 }
149 if !missing.is_empty() {
150 bail!(
151 "The following paths are not tracked by DVS:\n{}",
152 missing.join("\n")
153 );
154 }
155
156 Ok(out)
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use fs::File;
163 use fs_err as fs;
164 use tempfile::TempDir;
165
166 fn setup_test_repo() -> (TempDir, DvsPaths) {
168 let temp = TempDir::new().unwrap();
169 let root = temp.path();
170
171 fs::create_dir(root.join(".git")).unwrap();
173
174 fs::create_dir_all(root.join("data/subdir")).unwrap();
176 File::create(root.join("foo.txt")).unwrap();
177 File::create(root.join("bar.csv")).unwrap();
178 File::create(root.join("data/a.csv")).unwrap();
179 File::create(root.join("data/b.txt")).unwrap();
180 File::create(root.join("data/subdir/c.csv")).unwrap();
181
182 fs::create_dir_all(root.join(".dvs/data/subdir")).unwrap();
184 File::create(root.join(".dvs/foo.txt.dvs")).unwrap();
185 File::create(root.join(".dvs/data/a.csv.dvs")).unwrap();
186 File::create(root.join(".dvs/data/subdir/c.csv.dvs")).unwrap();
187
188 let dvs_paths = DvsPaths::new(root.to_path_buf(), root.to_path_buf(), ".dvs").unwrap();
189 (temp, dvs_paths)
190 }
191
192 #[test]
193 fn add_explicit_file_ignores_glob() {
194 let (_temp, dvs_paths) = setup_test_repo();
195 let result =
196 resolve_paths_for_add(vec![PathBuf::from("foo.txt")], Some("*.csv"), &dvs_paths)
197 .unwrap();
198
199 assert_eq!(result.len(), 1);
200 assert!(result.contains(&PathBuf::from("foo.txt")));
201 }
202
203 #[test]
204 fn add_directory_with_glob_filters() {
205 let (_temp, dvs_paths) = setup_test_repo();
206 let result =
207 resolve_paths_for_add(vec![PathBuf::from("data")], Some("*.csv"), &dvs_paths).unwrap();
208
209 assert!(result.contains(&PathBuf::from("data/a.csv")));
210 assert!(!result.contains(&PathBuf::from("data/b.txt")));
211 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
213 }
214
215 #[test]
216 fn add_directory_with_recursive_glob() {
217 let (_temp, dvs_paths) = setup_test_repo();
218 let result =
219 resolve_paths_for_add(vec![PathBuf::from("data")], Some("**/*.csv"), &dvs_paths)
220 .unwrap();
221
222 assert!(result.contains(&PathBuf::from("data/a.csv")));
223 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
224 assert!(!result.contains(&PathBuf::from("data/b.txt")));
225 }
226
227 #[test]
228 fn add_directory_without_glob() {
229 let (_temp, dvs_paths) = setup_test_repo();
230 let result = resolve_paths_for_add(
231 vec![PathBuf::from("data"), PathBuf::from("foo.txt")],
232 None,
233 &dvs_paths,
234 )
235 .unwrap();
236
237 assert!(result.contains(&PathBuf::from("data")));
239 assert!(result.contains(&PathBuf::from("foo.txt")));
240 }
241
242 #[test]
243 fn add_path_not_found_errors() {
244 let (_temp, dvs_paths) = setup_test_repo();
245 let result = resolve_paths_for_add(vec![PathBuf::from("nonexistent")], None, &dvs_paths);
246
247 assert!(result.is_err());
248 assert!(result.unwrap_err().to_string().contains("Path not found"));
249 }
250
251 #[test]
252 #[cfg(unix)]
253 fn add_walk_skips_symlink_resolving_outside_repo() {
254 use std::os::unix::fs::symlink;
255
256 let (_temp, dvs_paths) = setup_test_repo();
257 let outside = TempDir::new().unwrap();
258 let outside_file = outside.path().join("secret.csv");
259 File::create(&outside_file).unwrap();
260 symlink(&outside_file, dvs_paths.repo_root().join("data/link.csv")).unwrap();
262
263 let result =
264 resolve_paths_for_add(vec![PathBuf::from("data")], Some("*.csv"), &dvs_paths).unwrap();
265
266 assert!(result.contains(&PathBuf::from("data/a.csv")));
267 assert!(
268 !result
269 .iter()
270 .any(|p| p.ends_with("link.csv") || p.ends_with("secret.csv")),
271 );
272 }
273
274 #[test]
275 fn get_exact_file_match() {
276 let (_temp, dvs_paths) = setup_test_repo();
277 let result =
278 resolve_paths_for_get(vec![PathBuf::from("foo.txt")], None, &dvs_paths, false).unwrap();
279
280 assert_eq!(result.len(), 1);
281 assert!(result.contains(&PathBuf::from("foo.txt")));
282 }
283
284 #[test]
285 fn get_untracked_explicit_path_errors() {
286 let (_temp, dvs_paths) = setup_test_repo();
287 let result = resolve_paths_for_get(
290 vec![PathBuf::from("foo.txt"), PathBuf::from("bar.csv")],
291 None,
292 &dvs_paths,
293 false,
294 );
295
296 assert!(result.is_err());
297 let err = result.unwrap_err().to_string();
298 assert!(err.contains("not tracked"), "unexpected error: {err}");
299 assert!(err.contains("bar.csv"), "unexpected error: {err}");
300 }
301
302 #[test]
303 fn get_explicit_file_ignores_glob() {
304 let (_temp, dvs_paths) = setup_test_repo();
305 let result = resolve_paths_for_get(
306 vec![PathBuf::from("foo.txt")],
307 Some("*.csv"),
308 &dvs_paths,
309 false,
310 )
311 .unwrap();
312
313 assert_eq!(result.len(), 1);
314 assert!(result.contains(&PathBuf::from("foo.txt")));
315 }
316
317 #[test]
318 fn get_directory_recursive_returns_all_tracked() {
319 let (_temp, dvs_paths) = setup_test_repo();
320 let result =
321 resolve_paths_for_get(vec![PathBuf::from("data")], None, &dvs_paths, true).unwrap();
322
323 assert!(result.contains(&PathBuf::from("data/a.csv")));
324 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
325 assert!(!result.contains(&PathBuf::from("data/b.txt")));
327 }
328
329 #[test]
330 fn get_directory_non_recursive_excludes_subdirs() {
331 let (_temp, dvs_paths) = setup_test_repo();
332 let result =
333 resolve_paths_for_get(vec![PathBuf::from("data")], None, &dvs_paths, false).unwrap();
334
335 assert!(result.contains(&PathBuf::from("data/a.csv")));
336 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
337 assert!(!result.contains(&PathBuf::from("data/b.txt")));
338 }
339
340 #[test]
341 fn get_with_glob_filters() {
342 let (_temp, dvs_paths) = setup_test_repo();
343 let result = resolve_paths_for_get(vec![], Some("*.txt"), &dvs_paths, false).unwrap();
345
346 assert!(result.contains(&PathBuf::from("foo.txt")));
347 assert!(!result.contains(&PathBuf::from("data/a.csv")));
348 }
349
350 #[test]
351 fn get_no_paths_non_recursive_returns_direct_children() {
352 let (_temp, dvs_paths) = setup_test_repo();
353 let result = resolve_paths_for_get(vec![], None, &dvs_paths, false).unwrap();
356 assert!(result.contains(&PathBuf::from("foo.txt")));
357 assert!(!result.contains(&PathBuf::from("data/a.csv")));
358 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
359 }
360
361 #[test]
362 fn get_no_paths_recursive_returns_all_under_cwd() {
363 let (_temp, dvs_paths) = setup_test_repo();
364 let result = resolve_paths_for_get(vec![], None, &dvs_paths, true).unwrap();
365 assert!(result.contains(&PathBuf::from("foo.txt")));
366 assert!(result.contains(&PathBuf::from("data/a.csv")));
367 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
368 }
369
370 #[test]
371 fn get_dot_recursive_returns_all_tracked() {
372 let (_temp, dvs_paths) = setup_test_repo();
373 let result =
377 resolve_paths_for_get(vec![PathBuf::from(".")], None, &dvs_paths, true).unwrap();
378 assert!(result.contains(&PathBuf::from("foo.txt")));
379 assert!(result.contains(&PathBuf::from("data/a.csv")));
380 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
381 }
382
383 #[test]
384 fn get_absolute_file_path() {
385 let (temp, dvs_paths) = setup_test_repo();
386 let abs_path = temp.path().canonicalize().unwrap().join("foo.txt");
387 let result = resolve_paths_for_get(vec![abs_path], None, &dvs_paths, false).unwrap();
388
389 assert_eq!(result.len(), 1);
390 assert!(result.contains(&PathBuf::from("foo.txt")));
391 }
392
393 #[test]
394 fn get_absolute_directory_path() {
395 let (temp, dvs_paths) = setup_test_repo();
396 let abs_path = temp.path().canonicalize().unwrap().join("data");
397 let result = resolve_paths_for_get(vec![abs_path], None, &dvs_paths, true).unwrap();
398
399 assert!(result.contains(&PathBuf::from("data/a.csv")));
400 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
401 assert!(!result.contains(&PathBuf::from("foo.txt")));
402 }
403
404 #[test]
405 fn get_absolute_directory_path_non_recursive() {
406 let (temp, dvs_paths) = setup_test_repo();
407 let abs_path = temp.path().canonicalize().unwrap().join("data");
408 let result = resolve_paths_for_get(vec![abs_path], None, &dvs_paths, false).unwrap();
409
410 assert!(result.contains(&PathBuf::from("data/a.csv")));
411 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
412 assert!(!result.contains(&PathBuf::from("foo.txt")));
413 }
414
415 #[test]
416 fn get_directory_glob_is_dir_relative() {
417 let (_temp, dvs_paths) = setup_test_repo();
418 let result = resolve_paths_for_get(
419 vec![PathBuf::from("data")],
420 Some("*.csv"),
421 &dvs_paths,
422 false,
423 )
424 .unwrap();
425
426 assert!(result.contains(&PathBuf::from("data/a.csv")));
427 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
428 assert!(!result.contains(&PathBuf::from("foo.txt")));
429 }
430
431 #[test]
432 fn get_directory_recursive_glob_reaches_subdirs() {
433 let (_temp, dvs_paths) = setup_test_repo();
434 let result = resolve_paths_for_get(
435 vec![PathBuf::from("data")],
436 Some("**/*.csv"),
437 &dvs_paths,
438 false,
439 )
440 .unwrap();
441
442 assert!(result.contains(&PathBuf::from("data/a.csv")));
443 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
444 assert!(!result.contains(&PathBuf::from("foo.txt")));
445 }
446
447 #[test]
448 fn add_absolute_file_path() {
449 let (temp, dvs_paths) = setup_test_repo();
450 let abs_path = temp.path().canonicalize().unwrap().join("foo.txt");
451 let result = resolve_paths_for_add(vec![abs_path], None, &dvs_paths).unwrap();
452
453 assert_eq!(result.len(), 1);
454 assert!(result.contains(&PathBuf::from("foo.txt")));
455 }
456}