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, anyhow, bail};
6use globset::{GlobBuilder, GlobMatcher};
7use walkdir::WalkDir;
8
9/// Builds the glob matching the rg behaviour
10/// eg "*.csv" will not match `some/dir/test.csv`
11pub(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
23/// Resolve paths for `add` command following ripgrep-style behavior:
24/// - Explicit files: added directly (glob ignored)
25/// - Explicit directories: walked and filtered by glob
26/// - No paths + glob: walks cwd filtered by glob
27pub 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    // If no paths given, default to cwd
38    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        // Explicit file: we ignore the glob and add it to the file
52        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                // Outside repo: insert original user path; validate_for_add will catch it
56                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                // A dir without a glob: it will be rejected later
62                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                    // Skip directories and metadata root folder
67                    if !entry_path.is_file() || entry_path.starts_with(&metadata_root) {
68                        continue;
69                    }
70
71                    // Get path relative to the walked directory for matching
72                    let relative_to_dir = match entry_path.strip_prefix(&full_path) {
73                        Ok(p) => p,
74                        Err(_) => {
75                            // A symlink outside of the repo from a glob is a warning only
76                            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                        // Return path relative to repo root
88                        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
110/// Resolve paths for `get` command by scanning tracked metadata:
111/// - Explicit files or directories: filtered to tracked files under them
112/// - Glob: matched relative to each path argument, or relative to cwd when no paths are given
113/// - No paths + no glob: returns all tracked files directly under cwd
114pub 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    // No explicit paths: scope to cwd and return whatever matches.
124    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    // Explicit paths: every one must resolve to at least one tracked file,
133    // otherwise we refuse the whole batch rather than silently skipping it.
134    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    /// Creates a test repo structure with files and metadata
167    fn setup_test_repo() -> (TempDir, DvsPaths) {
168        let temp = TempDir::new().unwrap();
169        let root = temp.path();
170
171        // Create .git to mark repo root
172        fs::create_dir(root.join(".git")).unwrap();
173
174        // Create files
175        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        // Create .dvs metadata folder with tracked files
183        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        // *.csv should not match subdir/c.csv due to literal_separator
212        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        // We keep it, it will be rejected later down the line since it's a dir
238        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        // A symlink inside data/ that points outside the repo.
261        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        // bar.csv exists on disk but is not tracked; an explicit untracked path
288        // must error rather than be silently dropped.
289        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        // b.txt is not tracked
326        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        // Empty paths defaults to cwd, then glob filters
344        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        // No explicit paths scopes to cwd (the repo root here). Without
354        // `recursive`, only files directly under cwd are returned.
355        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        // `dvs get . --recursive` should match every tracked path: normalize_path
374        // strips the CurDir component, leaving an empty PathBuf that
375        // Path::starts_with treats as a prefix of any path.
376        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}