1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use crate::paths::{DvsPaths, PathFilter};
5use anyhow::{Result, bail};
6use globset::{GlobBuilder, GlobMatcher};
7use walkdir::WalkDir;
8
9#[derive(Debug)]
11pub struct NoFilesSelected(pub PathBuf);
12
13impl std::fmt::Display for NoFilesSelected {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 write!(f, "Path not found: {}", self.0.display())
16 }
17}
18
19impl std::error::Error for NoFilesSelected {}
20
21pub(crate) fn build_glob_matcher(pattern: Option<&str>) -> Result<Option<GlobMatcher>> {
24 pattern
25 .map(|p| {
26 GlobBuilder::new(p)
27 .literal_separator(true)
28 .build()
29 .map(|g| g.compile_matcher())
30 .map_err(Into::into)
31 })
32 .transpose()
33}
34
35pub fn resolve_paths_for_add(
40 paths: Vec<PathBuf>,
41 glob_pattern: Option<&str>,
42 dvs_paths: &DvsPaths,
43) -> Result<HashSet<PathBuf>> {
44 let mut out = HashSet::new();
45 let glob_matcher = build_glob_matcher(glob_pattern)?;
46 let repo_root = dvs_paths.repo_root().canonicalize()?;
47 let metadata_root = dvs_paths
50 .metadata_folder()
51 .canonicalize()
52 .unwrap_or_else(|_| dvs_paths.metadata_folder());
53
54 let paths = if paths.is_empty() {
56 vec![PathBuf::from(".")]
57 } else {
58 paths
59 };
60
61 for path in paths {
62 let full_path = dvs_paths
63 .cwd()
64 .join(&path)
65 .canonicalize()
66 .map_err(|_| NoFilesSelected(path.clone()))?;
67
68 if full_path.is_file() {
70 let relative_to_root = match full_path.strip_prefix(&repo_root) {
71 Ok(p) => p.to_path_buf(),
72 Err(_) => path.clone(),
74 };
75 out.insert(relative_to_root);
76 } else if full_path.is_dir() {
77 if !full_path.starts_with(&repo_root) {
78 out.insert(path.clone());
80 } else if let Some(matcher) = &glob_matcher {
81 for entry in WalkDir::new(&full_path).into_iter().filter_map(|e| e.ok()) {
82 let entry_path = entry.path().canonicalize()?;
83 if !entry_path.is_file() || entry_path.starts_with(&metadata_root) {
85 continue;
86 }
87 if entry.file_name() == ".gitignore" {
90 continue;
91 }
92
93 let relative_to_dir = match entry_path.strip_prefix(&full_path) {
95 Ok(p) => p,
96 Err(_) => {
97 if !entry_path.starts_with(&repo_root) {
99 log::warn!(
100 "Skipping {}: symlink resolves outside the project root ({})",
101 entry.path().display(),
102 entry_path.display()
103 );
104 }
105 continue;
106 }
107 };
108 if matcher.is_match(relative_to_dir) {
109 let relative_to_root = match entry_path.strip_prefix(&repo_root) {
111 Ok(p) => p.to_path_buf(),
112 Err(_) => continue,
113 };
114 out.insert(relative_to_root);
115 }
116 }
117 } else {
118 let relative_to_root = match full_path.strip_prefix(&repo_root) {
119 Ok(p) => p.to_path_buf(),
120 Err(_) => path.clone(),
121 };
122 out.insert(relative_to_root);
123 }
124 } else {
125 bail!("Path is not a file or directory: {}", path.display());
126 }
127 }
128
129 Ok(out)
130}
131
132#[derive(Debug, Default)]
135pub struct GetSelection {
136 pub tracked: HashSet<PathBuf>,
137 pub unmatched: Vec<PathBuf>,
138}
139
140pub fn select_paths_for_get(
147 paths: Vec<PathBuf>,
148 glob_pattern: Option<&str>,
149 dvs_paths: &DvsPaths,
150 recursive: bool,
151) -> Result<GetSelection> {
152 let glob_matcher = build_glob_matcher(glob_pattern)?;
153 let tracked = dvs_paths.tracked_paths();
154
155 if paths.is_empty() {
157 let filter = PathFilter::cwd_scoped(recursive, dvs_paths);
158 return Ok(GetSelection {
159 tracked: tracked
160 .into_iter()
161 .filter(|t| filter.matches(t, glob_matcher.as_ref()))
162 .collect(),
163 unmatched: Vec::new(),
164 });
165 }
166
167 let mut selection = GetSelection::default();
168 for path in paths {
169 let filter = PathFilter::from_user_paths(vec![path.clone()], recursive, dvs_paths);
170 let mut matched_any = false;
171 for tracked_path in &tracked {
172 if filter.matches(tracked_path, glob_matcher.as_ref()) {
173 selection.tracked.insert(tracked_path.clone());
174 matched_any = true;
175 }
176 }
177 if !matched_any {
178 selection.unmatched.push(path);
179 }
180 }
181 Ok(selection)
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use fs::File;
188 use fs_err as fs;
189 use tempfile::TempDir;
190
191 fn setup_test_repo() -> (TempDir, DvsPaths) {
193 let temp = TempDir::new().unwrap();
194 let root = temp.path();
195
196 fs::create_dir(root.join(".git")).unwrap();
198
199 fs::create_dir_all(root.join("data/subdir")).unwrap();
201 File::create(root.join("foo.txt")).unwrap();
202 File::create(root.join("bar.csv")).unwrap();
203 File::create(root.join("data/a.csv")).unwrap();
204 File::create(root.join("data/b.txt")).unwrap();
205 File::create(root.join("data/subdir/c.csv")).unwrap();
206
207 fs::create_dir_all(root.join(".dvs/data/subdir")).unwrap();
209 File::create(root.join(".dvs/foo.txt.dvs")).unwrap();
210 File::create(root.join(".dvs/data/a.csv.dvs")).unwrap();
211 File::create(root.join(".dvs/data/subdir/c.csv.dvs")).unwrap();
212
213 let dvs_paths = DvsPaths::new(root.to_path_buf(), root.to_path_buf(), ".dvs").unwrap();
214 (temp, dvs_paths)
215 }
216
217 #[test]
218 fn add_without_metadata_folder() {
219 let (_temp, dvs_paths) = setup_test_repo();
221 fs::remove_dir_all(dvs_paths.metadata_folder()).unwrap();
222
223 let result =
224 resolve_paths_for_add(vec![PathBuf::from("data/a.csv")], None, &dvs_paths).unwrap();
225 assert_eq!(result, HashSet::from([PathBuf::from("data/a.csv")]));
226
227 let result =
228 resolve_paths_for_add(vec![PathBuf::from("data")], Some("*.csv"), &dvs_paths).unwrap();
229 assert_eq!(result, HashSet::from([PathBuf::from("data/a.csv")]));
230 }
231
232 #[test]
233 fn add_explicit_file_ignores_glob() {
234 let (_temp, dvs_paths) = setup_test_repo();
235 let result =
236 resolve_paths_for_add(vec![PathBuf::from("foo.txt")], Some("*.csv"), &dvs_paths)
237 .unwrap();
238
239 assert_eq!(result.len(), 1);
240 assert!(result.contains(&PathBuf::from("foo.txt")));
241 }
242
243 #[test]
244 fn add_directory_with_glob_filters() {
245 let (_temp, dvs_paths) = setup_test_repo();
246 let result =
247 resolve_paths_for_add(vec![PathBuf::from("data")], Some("*.csv"), &dvs_paths).unwrap();
248
249 assert!(result.contains(&PathBuf::from("data/a.csv")));
250 assert!(!result.contains(&PathBuf::from("data/b.txt")));
251 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
253 }
254
255 #[test]
256 fn add_directory_with_recursive_glob() {
257 let (_temp, dvs_paths) = setup_test_repo();
258 let result =
259 resolve_paths_for_add(vec![PathBuf::from("data")], Some("**/*.csv"), &dvs_paths)
260 .unwrap();
261
262 assert!(result.contains(&PathBuf::from("data/a.csv")));
263 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
264 assert!(!result.contains(&PathBuf::from("data/b.txt")));
265 }
266
267 #[test]
268 fn add_directory_without_glob() {
269 let (_temp, dvs_paths) = setup_test_repo();
270 let result = resolve_paths_for_add(
271 vec![PathBuf::from("data"), PathBuf::from("foo.txt")],
272 None,
273 &dvs_paths,
274 )
275 .unwrap();
276
277 assert!(result.contains(&PathBuf::from("data")));
279 assert!(result.contains(&PathBuf::from("foo.txt")));
280 }
281
282 #[test]
283 fn add_glob_skips_gitignore_unless_explicit() {
284 let (_temp, dvs_paths) = setup_test_repo();
285 File::create(dvs_paths.repo_root().join("data/.gitignore")).unwrap();
286 File::create(dvs_paths.repo_root().join("data/subdir/.gitignore")).unwrap();
287
288 let walked =
289 resolve_paths_for_add(vec![PathBuf::from("data")], Some("**/*"), &dvs_paths).unwrap();
290 assert!(walked.contains(&PathBuf::from("data/a.csv")));
291 assert!(!walked.iter().any(|p| p.ends_with(".gitignore")));
292
293 let walked = resolve_paths_for_add(vec![], Some("data/*"), &dvs_paths).unwrap();
294 assert!(!walked.iter().any(|p| p.ends_with(".gitignore")));
295
296 let explicit =
297 resolve_paths_for_add(vec![PathBuf::from("data/.gitignore")], None, &dvs_paths)
298 .unwrap();
299 assert!(explicit.contains(&PathBuf::from("data/.gitignore")));
300 }
301
302 #[test]
303 fn add_path_not_found_errors() {
304 let (_temp, dvs_paths) = setup_test_repo();
305 let result = resolve_paths_for_add(vec![PathBuf::from("nonexistent")], None, &dvs_paths);
306
307 assert!(result.is_err());
308 assert!(result.unwrap_err().to_string().contains("Path not found"));
309 }
310
311 #[test]
312 #[cfg(unix)]
313 fn add_walk_skips_symlink_resolving_outside_repo() {
314 use std::os::unix::fs::symlink;
315
316 let (_temp, dvs_paths) = setup_test_repo();
317 let outside = TempDir::new().unwrap();
318 let outside_file = outside.path().join("secret.csv");
319 File::create(&outside_file).unwrap();
320 symlink(&outside_file, dvs_paths.repo_root().join("data/link.csv")).unwrap();
322
323 let result =
324 resolve_paths_for_add(vec![PathBuf::from("data")], Some("*.csv"), &dvs_paths).unwrap();
325
326 assert!(result.contains(&PathBuf::from("data/a.csv")));
327 assert!(
328 !result
329 .iter()
330 .any(|p| p.ends_with("link.csv") || p.ends_with("secret.csv")),
331 );
332 }
333
334 #[test]
335 fn get_exact_file_match() {
336 let (_temp, dvs_paths) = setup_test_repo();
337 let result = select_paths_for_get(vec![PathBuf::from("foo.txt")], None, &dvs_paths, false)
338 .unwrap()
339 .tracked;
340
341 assert_eq!(result.len(), 1);
342 assert!(result.contains(&PathBuf::from("foo.txt")));
343 }
344
345 #[test]
346 fn get_untracked_explicit_path_is_unmatched() {
347 let (_temp, dvs_paths) = setup_test_repo();
348 let result = select_paths_for_get(
350 vec![PathBuf::from("foo.txt"), PathBuf::from("bar.csv")],
351 None,
352 &dvs_paths,
353 false,
354 )
355 .unwrap();
356
357 assert!(result.tracked.contains(&PathBuf::from("foo.txt")));
358 assert_eq!(result.unmatched, vec![PathBuf::from("bar.csv")]);
359 }
360
361 #[test]
362 fn get_explicit_file_ignores_glob() {
363 let (_temp, dvs_paths) = setup_test_repo();
364 let result = select_paths_for_get(
365 vec![PathBuf::from("foo.txt")],
366 Some("*.csv"),
367 &dvs_paths,
368 false,
369 )
370 .unwrap()
371 .tracked;
372
373 assert_eq!(result.len(), 1);
374 assert!(result.contains(&PathBuf::from("foo.txt")));
375 }
376
377 #[test]
378 fn get_directory_recursive_returns_all_tracked() {
379 let (_temp, dvs_paths) = setup_test_repo();
380 let result = select_paths_for_get(vec![PathBuf::from("data")], None, &dvs_paths, true)
381 .unwrap()
382 .tracked;
383
384 assert!(result.contains(&PathBuf::from("data/a.csv")));
385 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
386 assert!(!result.contains(&PathBuf::from("data/b.txt")));
388 }
389
390 #[test]
391 fn get_directory_non_recursive_excludes_subdirs() {
392 let (_temp, dvs_paths) = setup_test_repo();
393 let result = select_paths_for_get(vec![PathBuf::from("data")], None, &dvs_paths, false)
394 .unwrap()
395 .tracked;
396
397 assert!(result.contains(&PathBuf::from("data/a.csv")));
398 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
399 assert!(!result.contains(&PathBuf::from("data/b.txt")));
400 }
401
402 #[test]
403 fn get_with_glob_filters() {
404 let (_temp, dvs_paths) = setup_test_repo();
405 let result = select_paths_for_get(vec![], Some("*.txt"), &dvs_paths, false)
407 .unwrap()
408 .tracked;
409
410 assert!(result.contains(&PathBuf::from("foo.txt")));
411 assert!(!result.contains(&PathBuf::from("data/a.csv")));
412 }
413
414 #[test]
415 fn get_no_paths_non_recursive_returns_direct_children() {
416 let (_temp, dvs_paths) = setup_test_repo();
417 let result = select_paths_for_get(vec![], None, &dvs_paths, false)
420 .unwrap()
421 .tracked;
422 assert!(result.contains(&PathBuf::from("foo.txt")));
423 assert!(!result.contains(&PathBuf::from("data/a.csv")));
424 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
425 }
426
427 #[test]
428 fn get_no_paths_recursive_returns_all_under_cwd() {
429 let (_temp, dvs_paths) = setup_test_repo();
430 let result = select_paths_for_get(vec![], None, &dvs_paths, true)
431 .unwrap()
432 .tracked;
433 assert!(result.contains(&PathBuf::from("foo.txt")));
434 assert!(result.contains(&PathBuf::from("data/a.csv")));
435 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
436 }
437
438 #[test]
439 fn get_dot_recursive_returns_all_tracked() {
440 let (_temp, dvs_paths) = setup_test_repo();
441 let result = select_paths_for_get(vec![PathBuf::from(".")], None, &dvs_paths, true)
445 .unwrap()
446 .tracked;
447 assert!(result.contains(&PathBuf::from("foo.txt")));
448 assert!(result.contains(&PathBuf::from("data/a.csv")));
449 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
450 }
451
452 #[test]
453 fn get_absolute_file_path() {
454 let (temp, dvs_paths) = setup_test_repo();
455 let abs_path = temp.path().canonicalize().unwrap().join("foo.txt");
456 let result = select_paths_for_get(vec![abs_path], None, &dvs_paths, false)
457 .unwrap()
458 .tracked;
459
460 assert_eq!(result.len(), 1);
461 assert!(result.contains(&PathBuf::from("foo.txt")));
462 }
463
464 #[test]
465 fn get_absolute_directory_path() {
466 let (temp, dvs_paths) = setup_test_repo();
467 let abs_path = temp.path().canonicalize().unwrap().join("data");
468 let result = select_paths_for_get(vec![abs_path], None, &dvs_paths, true)
469 .unwrap()
470 .tracked;
471
472 assert!(result.contains(&PathBuf::from("data/a.csv")));
473 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
474 assert!(!result.contains(&PathBuf::from("foo.txt")));
475 }
476
477 #[test]
478 fn get_absolute_directory_path_non_recursive() {
479 let (temp, dvs_paths) = setup_test_repo();
480 let abs_path = temp.path().canonicalize().unwrap().join("data");
481 let result = select_paths_for_get(vec![abs_path], None, &dvs_paths, false)
482 .unwrap()
483 .tracked;
484
485 assert!(result.contains(&PathBuf::from("data/a.csv")));
486 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
487 assert!(!result.contains(&PathBuf::from("foo.txt")));
488 }
489
490 #[test]
491 fn get_directory_glob_is_dir_relative() {
492 let (_temp, dvs_paths) = setup_test_repo();
493 let result = select_paths_for_get(
494 vec![PathBuf::from("data")],
495 Some("*.csv"),
496 &dvs_paths,
497 false,
498 )
499 .unwrap()
500 .tracked;
501
502 assert!(result.contains(&PathBuf::from("data/a.csv")));
503 assert!(!result.contains(&PathBuf::from("data/subdir/c.csv")));
504 assert!(!result.contains(&PathBuf::from("foo.txt")));
505 }
506
507 #[test]
508 fn get_directory_recursive_glob_reaches_subdirs() {
509 let (_temp, dvs_paths) = setup_test_repo();
510 let result = select_paths_for_get(
511 vec![PathBuf::from("data")],
512 Some("**/*.csv"),
513 &dvs_paths,
514 false,
515 )
516 .unwrap()
517 .tracked;
518
519 assert!(result.contains(&PathBuf::from("data/a.csv")));
520 assert!(result.contains(&PathBuf::from("data/subdir/c.csv")));
521 assert!(!result.contains(&PathBuf::from("foo.txt")));
522 }
523
524 #[test]
525 fn add_absolute_file_path() {
526 let (temp, dvs_paths) = setup_test_repo();
527 let abs_path = temp.path().canonicalize().unwrap().join("foo.txt");
528 let result = resolve_paths_for_add(vec![abs_path], None, &dvs_paths).unwrap();
529
530 assert_eq!(result.len(), 1);
531 assert!(result.contains(&PathBuf::from("foo.txt")));
532 }
533}