Skip to main content
← dvs documentation Rust API reference

dvs/
globbing.rs

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/// A valid add selection that names a path that does not exist.
10#[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
21/// Builds the glob matching the rg behaviour
22/// eg "*.csv" will not match `some/dir/test.csv`
23pub(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
35/// Resolve paths for `add` command following ripgrep-style behavior:
36/// - Explicit files: added directly (glob ignored)
37/// - Explicit directories: walked and filtered by glob
38/// - No paths + glob: walks cwd filtered by glob
39pub 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    // The metadata folder may not exist yet, eg before the first add in an attached
48    // project: there is nothing in it to skip then
49    let metadata_root = dvs_paths
50        .metadata_folder()
51        .canonicalize()
52        .unwrap_or_else(|_| dvs_paths.metadata_folder());
53
54    // If no paths given, default to cwd
55    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        // Explicit file: we ignore the glob and add it to the file
69        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                // Outside repo: insert original user path; validate_for_add will catch it
73                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                // A dir without a glob: it will be rejected later
79                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                    // Skip directories and metadata root folder
84                    if !entry_path.is_file() || entry_path.starts_with(&metadata_root) {
85                        continue;
86                    }
87                    // dvs writes .gitignore files next to tracked data: only an
88                    // explicit path adds one, never a glob
89                    if entry.file_name() == ".gitignore" {
90                        continue;
91                    }
92
93                    // Get path relative to the walked directory for matching
94                    let relative_to_dir = match entry_path.strip_prefix(&full_path) {
95                        Ok(p) => p,
96                        Err(_) => {
97                            // A symlink outside of the repo from a glob is a warning only
98                            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                        // Return path relative to repo root
110                        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/// What a `get` request selected: repo-root-relative tracked files, plus the
133/// explicit paths (as the user wrote them) that matched no tracked file.
134#[derive(Debug, Default)]
135pub struct GetSelection {
136    pub tracked: HashSet<PathBuf>,
137    pub unmatched: Vec<PathBuf>,
138}
139
140/// Resolve paths for `get` command by scanning tracked metadata:
141/// - Explicit files or directories: filtered to tracked files under them
142/// - Glob: matched relative to each path argument, or relative to cwd when no paths are given
143/// - No paths + no glob: returns all tracked files directly under cwd
144///
145/// Explicit paths that match nothing are returned in `unmatched`.
146pub 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    // No explicit paths: scope to cwd and return whatever matches.
156    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    /// Creates a test repo structure with files and metadata
192    fn setup_test_repo() -> (TempDir, DvsPaths) {
193        let temp = TempDir::new().unwrap();
194        let root = temp.path();
195
196        // Create .git to mark repo root
197        fs::create_dir(root.join(".git")).unwrap();
198
199        // Create files
200        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        // Create .dvs metadata folder with tracked files
208        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        // An attached project has no metadata folder until something is added
220        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        // *.csv should not match subdir/c.csv due to literal_separator
252        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        // We keep it, it will be rejected later down the line since it's a dir
278        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        // A symlink inside data/ that points outside the repo.
321        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        // bar.csv exists on disk but is not tracked
349        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        // b.txt is not tracked
387        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        // Empty paths defaults to cwd, then glob filters
406        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        // No explicit paths scopes to cwd (the repo root here). Without
418        // `recursive`, only files directly under cwd are returned.
419        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        // `dvs get . --recursive` should match every tracked path: normalize_path
442        // strips the CurDir component, leaving an empty PathBuf that
443        // Path::starts_with treats as a prefix of any path.
444        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}