Skip to main content
← dvs documentation Rust API reference

dvs/
paths.rs

1use std::ffi::OsStr;
2use std::fmt;
3use std::fmt::Formatter;
4use std::path::{Component, Path, PathBuf};
5
6use anyhow::Result;
7use fs_err as fs;
8use globset::GlobMatcher;
9use serde::{Deserialize, Deserializer, Serialize};
10use walkdir::WalkDir;
11
12use crate::config::Config;
13
14pub const CONFIG_FILE_NAME: &str = "dvs.toml";
15pub const DEFAULT_FOLDER_NAME: &str = ".dvs";
16
17/// Result of validating a file path for `add`.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum AddPathStatus {
20    /// File exists and is inside the project
21    Valid,
22    /// File does not exist on disk
23    NotFound,
24    /// Path is a directory, not a file
25    IsDirectory,
26    /// Path resolves to outside the project root
27    OutsideProject,
28}
29
30impl AddPathStatus {
31    pub fn reason(&self) -> Option<&str> {
32        match self {
33            AddPathStatus::Valid => None,
34            AddPathStatus::NotFound => Some("file not found"),
35            AddPathStatus::OutsideProject => Some("path is outside project"),
36            AddPathStatus::IsDirectory => Some("path is a directory"),
37        }
38    }
39}
40
41/// Result of validating a file path for `get`.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum GetPathStatus {
44    /// Metadata exists: file is tracked
45    Tracked,
46    /// No metadata and no file on disk
47    NotFound,
48    /// File exists on disk but is not tracked by DVS
49    NotTracked,
50}
51
52impl GetPathStatus {
53    pub fn reason(&self) -> Option<&str> {
54        match self {
55            GetPathStatus::Tracked => None,
56            GetPathStatus::NotFound => Some("file not found"),
57            GetPathStatus::NotTracked => Some("not tracked by DVS"),
58        }
59    }
60}
61
62/// Finds the root of a project by walking up from the given directory
63/// until a `dvs.toml` is found
64///
65/// Returns the `start_dir` if no `dvs.toml` has been found.
66pub fn find_repo_root(start_dir: impl AsRef<Path>) -> PathBuf {
67    let mut dir = start_dir.as_ref();
68    log::debug!("Searching for repo root starting from {}", dir.display());
69
70    loop {
71        if dir.join(CONFIG_FILE_NAME).exists() {
72            log::debug!("Found repo root at {}", dir.display());
73            return dir.to_path_buf();
74        }
75
76        if let Some(parent) = dir.parent() {
77            dir = parent;
78        } else {
79            break;
80        }
81    }
82
83    start_dir.as_ref().to_path_buf()
84}
85
86/// Normalize a path by resolving `.` and `..` components lexically (no
87/// canonicalization — the path may not exist and we want it relative to the
88/// directory, with no symlink resolution). Returns `None` if `..` escapes the
89/// base (pops past the root). Shared by `globbing` and `status` path filtering.
90pub(crate) fn normalize_path(p: PathBuf) -> Option<PathBuf> {
91    let mut out = PathBuf::new();
92    for c in p.components() {
93        match c {
94            Component::CurDir => {}
95            Component::ParentDir => {
96                if !out.pop() {
97                    return None;
98                }
99            }
100            _ => out.push(c),
101        }
102    }
103    Some(out)
104}
105
106/// We always need to figure out where the user is in a project,
107/// where the root is etc.
108/// This struct handles all of it so the rest of the code doesn't have to
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct DvsPaths {
111    /// Canonicalized path of where the user currently is
112    cwd: PathBuf,
113    /// Canonicalized path where our `dvs.toml` is
114    repo_root: PathBuf,
115    /// Folder name for metadata, defined in the config
116    metadata_folder_name: String,
117}
118
119impl DvsPaths {
120    /// Create with explicit paths (for testing or R package)
121    pub fn new(
122        cwd: PathBuf,
123        repo_root: PathBuf,
124        metadata_folder_name: impl Into<String>,
125    ) -> Result<Self> {
126        Ok(Self {
127            cwd,
128            repo_root: fs::canonicalize(&repo_root)?,
129            metadata_folder_name: metadata_folder_name.into(),
130        })
131    }
132
133    pub fn from_cwd(config: &Config) -> Result<Self> {
134        let cwd = fs::canonicalize(std::env::current_dir()?)?;
135        let repo_root = fs::canonicalize(find_repo_root(&cwd))?;
136
137        log::debug!(
138            "Resolved paths: cwd={}, repo_root={}",
139            cwd.display(),
140            repo_root.display()
141        );
142        Ok(Self {
143            cwd,
144            repo_root,
145            metadata_folder_name: config.metadata_folder_name().to_owned(),
146        })
147    }
148
149    pub fn metadata_folder(&self) -> PathBuf {
150        self.repo_root.join(&self.metadata_folder_name)
151    }
152
153    pub fn cache_folder(&self) -> PathBuf {
154        self.metadata_folder().join(".cache")
155    }
156
157    pub fn metadata_path(&self, relative: &Path) -> PathBuf {
158        let dvs_path = self.metadata_folder().join(relative);
159        let mut s = dvs_path.into_os_string();
160        s.push(".dvs");
161        PathBuf::from(s)
162    }
163
164    /// Repo-root-relative paths of every tracked file: one per `.dvs` entry in
165    /// the metadata folder, with the `.dvs` extension stripped.
166    /// Shared by `get` and `status`
167    pub(crate) fn tracked_paths(&self) -> Vec<PathBuf> {
168        let metadata_root = self.metadata_folder();
169        WalkDir::new(&metadata_root)
170            .into_iter()
171            .filter_map(|e| e.ok())
172            .filter_map(|entry| {
173                let entry_path = entry.path();
174                if !entry_path.is_file() || entry_path.extension() != Some(OsStr::new("dvs")) {
175                    return None;
176                }
177                entry_path
178                    .strip_prefix(&metadata_root)
179                    .ok()
180                    .map(|rel| rel.with_extension(""))
181            })
182            .collect()
183    }
184
185    pub fn repo_root(&self) -> &Path {
186        &self.repo_root
187    }
188
189    pub fn cwd(&self) -> &Path {
190        &self.cwd
191    }
192
193    /// Get the path relative from repo root to cwd, or None if at repo root
194    pub fn cwd_relative_to_root(&self) -> Option<&Path> {
195        self.cwd
196            .strip_prefix(&self.repo_root)
197            .ok()
198            .filter(|p| !p.as_os_str().is_empty())
199    }
200
201    /// Construct the full file path from a repo-relative path
202    pub fn file_path(&self, relative: &Path) -> PathBuf {
203        self.repo_root.join(relative)
204    }
205
206    pub fn validate_for_add(&self, paths: &[PathBuf]) -> Vec<(PathBuf, AddPathStatus)> {
207        let mut found = Vec::new();
208        for path in paths {
209            let file_path = self.file_path(path);
210            let status = match file_path.canonicalize() {
211                Ok(canonical) => {
212                    if !canonical.starts_with(&self.repo_root) {
213                        AddPathStatus::OutsideProject
214                    } else if canonical.is_dir() {
215                        AddPathStatus::IsDirectory
216                    } else if canonical.is_file() {
217                        AddPathStatus::Valid
218                    } else {
219                        AddPathStatus::NotFound
220                    }
221                }
222                Err(_) => AddPathStatus::NotFound,
223            };
224            found.push((path.clone(), status));
225        }
226        found
227    }
228
229    pub fn validate_for_get(&self, paths: &[PathBuf]) -> Vec<(PathBuf, GetPathStatus)> {
230        let mut found = Vec::new();
231        for path in paths {
232            let metadata_path = self.metadata_path(path);
233            let validation = if metadata_path.is_file() {
234                GetPathStatus::Tracked
235            } else if self.file_path(path).is_file() {
236                GetPathStatus::NotTracked
237            } else {
238                GetPathStatus::NotFound
239            };
240            found.push((path.clone(), validation));
241        }
242        found
243    }
244}
245
246/// Which paths to get use for status/get
247/// eg you can pass dir1/ dir2/ and it will expand to dir1/* dir2/*
248/// If `recursive` is `true`, then it will expand to dir1/**/* dir2/**/*
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250pub struct PathFilter {
251    pub(crate) paths: Vec<PathBuf>,
252    pub(crate) recursive: bool,
253}
254
255impl PathFilter {
256    /// Normalized project-relative selections, after rejecting paths outside the root.
257    pub fn paths(&self) -> &[PathBuf] {
258        &self.paths
259    }
260
261    /// When a user doesn't provide a path, we default to the cwd relative to the root
262    pub fn cwd_scoped(recursive: bool, dvs_paths: &DvsPaths) -> Self {
263        let cwd = dvs_paths
264            .cwd_relative_to_root()
265            .map(Path::to_path_buf)
266            .unwrap_or_default(); // "" when at repo root
267        Self {
268            paths: vec![cwd],
269            recursive,
270        }
271    }
272
273    /// Create a filter from user-provided paths (relative to cwd) and a recursive flag.
274    /// Translates cwd-relative paths to repo-root-relative using `dvs_paths.cwd_relative_to_root()`.
275    pub fn from_user_paths(
276        user_paths: Vec<PathBuf>,
277        recursive: bool,
278        dvs_paths: &DvsPaths,
279    ) -> Self {
280        let cwd_prefix = dvs_paths.cwd_relative_to_root();
281        let repo_root = dvs_paths.repo_root();
282        let paths = user_paths
283            .into_iter()
284            .filter_map(|p| {
285                if p.is_absolute() {
286                    p.strip_prefix(repo_root)
287                        .ok()
288                        .map(|r| r.to_path_buf())
289                        .and_then(normalize_path)
290                } else {
291                    let joined = if let Some(prefix) = cwd_prefix {
292                        prefix.join(&p)
293                    } else {
294                        p
295                    };
296                    normalize_path(joined)
297                }
298            })
299            .collect();
300        Self { paths, recursive }
301    }
302
303    pub(crate) fn matches(&self, tracked_path: &Path, glob: Option<&GlobMatcher>) -> bool {
304        self.paths.iter().any(|filter_path| {
305            if tracked_path == filter_path {
306                return true;
307            }
308            // it needs to be a parent
309            let Ok(rel) = tracked_path.strip_prefix(filter_path) else {
310                return false;
311            };
312            match glob {
313                Some(g) => g.is_match(rel),
314                None => {
315                    // Recursive: any descendant or non-recursive: direct child
316                    self.recursive || tracked_path.parent() == Some(filter_path.as_path())
317                }
318            }
319        })
320    }
321}
322
323#[derive(Debug, PartialEq, Eq)]
324pub enum ProjectPathError {
325    Empty,
326    NotRelative,
327    NotUtf8,
328    Backslash,
329}
330
331impl fmt::Display for ProjectPathError {
332    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
333        match self {
334            ProjectPathError::Empty => f.write_str("Path is empty or has an empty component"),
335            ProjectPathError::NotRelative => {
336                f.write_str("Path needs to be relative, without a leading `/`, `.` or `..`")
337            }
338            ProjectPathError::NotUtf8 => f.write_str("Path is not UTF-8"),
339            ProjectPathError::Backslash => f.write_str("Part of the path contains `\\`"),
340        }
341    }
342}
343
344/// We want a path to be the same on all OSes: we need to be able to push on Windows and
345/// pull on Linux.
346#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
347#[serde(transparent)]
348pub struct ProjectPath(String);
349
350impl ProjectPath {
351    pub fn from_path(path: &Path) -> Result<Self, ProjectPathError> {
352        let mut out = String::new();
353        for component in path.components() {
354            let Component::Normal(c) = component else {
355                return Err(ProjectPathError::NotRelative);
356            };
357            let part = c.to_str().ok_or(ProjectPathError::NotUtf8)?;
358            if part.contains('\\') {
359                return Err(ProjectPathError::Backslash);
360            }
361            if !out.is_empty() {
362                out.push('/');
363            }
364            out.push_str(part);
365        }
366        if out.is_empty() {
367            return Err(ProjectPathError::Empty);
368        }
369        Ok(Self(out))
370    }
371
372    /// This is like from_path but from a canonical dvs path, eg using / even on Windows
373    pub fn from_canonical(path: &str) -> Result<Self, ProjectPathError> {
374        if path.starts_with('/') {
375            return Err(ProjectPathError::NotRelative);
376        }
377        let mut out = String::with_capacity(path.len());
378        for part in path.split('/') {
379            match part {
380                "" => return Err(ProjectPathError::Empty),
381                "." | ".." => return Err(ProjectPathError::NotRelative),
382                _ => {
383                    if part.contains('\\') {
384                        return Err(ProjectPathError::Backslash);
385                    }
386                    if !out.is_empty() {
387                        out.push('/');
388                    }
389                    out.push_str(part);
390                }
391            }
392        }
393
394        if out.is_empty() {
395            return Err(ProjectPathError::Empty);
396        }
397
398        Ok(Self(out))
399    }
400
401    pub fn as_str(&self) -> &str {
402        &self.0
403    }
404
405    pub fn to_native(&self) -> PathBuf {
406        self.0.split('/').collect()
407    }
408}
409
410impl<'de> Deserialize<'de> for ProjectPath {
411    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
412        let s = String::deserialize(de)?;
413        ProjectPath::from_canonical(&s).map_err(serde::de::Error::custom)
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::testutil::{create_temp_git_repo, init_dvs_repo};
421
422    #[test]
423    fn metadata_path_returns_dvs_file_path() {
424        let (_tmp, root) = create_temp_git_repo();
425        let paths = DvsPaths::new(root.clone(), root.clone(), ".meta").unwrap();
426
427        let result = paths.metadata_path(Path::new("sub/file.txt"));
428        assert_eq!(result, root.join(".meta/sub/file.txt.dvs"));
429    }
430
431    #[test]
432    fn validate_for_add() {
433        let (_tmp, root) = create_temp_git_repo();
434        let paths = DvsPaths::new(root.clone(), root.clone(), ".dvs").unwrap();
435
436        // Valid: existing file inside repo
437        fs_err::write(root.join("test.txt"), b"content").unwrap();
438
439        // OutsideProject: a sibling of the repo, outside its root.
440        fs_err::write(root.parent().unwrap().join("outside.txt"), b"outside").unwrap();
441        let outside_relative = PathBuf::from("..").join("outside.txt");
442
443        // IsDirectory: a subdirectory inside the repo
444        fs_err::create_dir(root.join("subdir")).unwrap();
445
446        let result = paths.validate_for_add(&[
447            PathBuf::from("test.txt"),
448            PathBuf::from("nonexistent.txt"),
449            outside_relative,
450            PathBuf::from("subdir"),
451        ]);
452
453        assert_eq!(result[0].1, AddPathStatus::Valid);
454        assert_eq!(result[1].1, AddPathStatus::NotFound);
455        assert_eq!(result[2].1, AddPathStatus::OutsideProject);
456        assert_eq!(result[3].1, AddPathStatus::IsDirectory);
457    }
458
459    #[test]
460    fn can_handle_project_paths() {
461        assert_eq!(
462            ProjectPath::from_path(Path::new("data/file.csv"))
463                .unwrap()
464                .as_str(),
465            "data/file.csv"
466        );
467        #[cfg(unix)]
468        assert_eq!(
469            ProjectPath::from_path(Path::new("data\\file.csv")),
470            Err(ProjectPathError::Backslash)
471        );
472        #[cfg(windows)]
473        assert_eq!(
474            ProjectPath::from_path(Path::new("data\\file.csv"))
475                .unwrap()
476                .as_str(),
477            "data/file.csv"
478        );
479
480        let paths = vec!["../file.csv", "./file.csv"];
481        for p in paths {
482            assert!(ProjectPath::from_path(Path::new(p)).is_err());
483        }
484    }
485
486    #[test]
487    fn canonical_project_path_errors() {
488        for (input, expected) in [
489            ("/abs/file.csv", ProjectPathError::NotRelative),
490            ("../file.csv", ProjectPathError::NotRelative),
491            ("./file.csv", ProjectPathError::NotRelative),
492            ("data\\file.csv", ProjectPathError::Backslash),
493            ("data//file.csv", ProjectPathError::Empty),
494            ("data/", ProjectPathError::Empty),
495            ("", ProjectPathError::Empty),
496        ] {
497            assert_eq!(ProjectPath::from_canonical(input), Err(expected),);
498        }
499    }
500
501    #[test]
502    fn canonical_project_path_round_trip() {
503        let path = ProjectPath::from_canonical("data/sub/file.csv").unwrap();
504        assert_eq!(path.as_str(), "data/sub/file.csv");
505        assert_eq!(ProjectPath::from_path(&path.to_native()), Ok(path));
506    }
507
508    fn run_filter_cases(cases: Vec<(&[&str], &str, bool)>, recursive: bool) {
509        for (filter_paths, test_path, expected) in cases {
510            let filter = PathFilter {
511                paths: filter_paths
512                    .iter()
513                    .filter_map(|p| normalize_path(PathBuf::from(p)))
514                    .collect(),
515                recursive,
516            };
517            assert_eq!(
518                filter.matches(Path::new(test_path), None),
519                expected,
520                "filter={filter_paths:?} recursive={recursive} path={test_path:?}"
521            );
522        }
523    }
524
525    #[test]
526    fn status_filter_matches_non_recursive() {
527        // (filter_paths, test_path, expected)
528        let cases: Vec<(&[&str], &str, bool)> = vec![
529            // direct child matches
530            (&["dir1"], "dir1/b.txt", true),
531            // nested child does NOT match
532            (&["dir1"], "dir1/sub/c.txt", false),
533            // exact file match
534            (&["dir2/d.txt"], "dir2/d.txt", true),
535            // exact file: different file does NOT match
536            (&["dir2/d.txt"], "dir2/e.txt", false),
537            // "." matches root-level files
538            (&["."], "a.txt", true),
539            // "." does NOT match nested files
540            (&["."], "dir1/b.txt", false),
541            // "../foo" escapes root → dropped, matches nothing
542            (&["../foo"], "foo", false),
543            // "dir1/../dir2" normalizes to "dir2", matches direct child
544            (&["dir1/../dir2"], "dir2/d.txt", true),
545            // "dir1/.." normalizes to root, matches root-level files
546            (&["dir1/.."], "a.txt", true),
547        ];
548        run_filter_cases(cases, false);
549    }
550
551    #[test]
552    fn status_filter_matches_recursive() {
553        // (filter_paths, test_path, expected)
554        let cases: Vec<(&[&str], &str, bool)> = vec![
555            // direct child matches
556            (&["dir1"], "dir1/b.txt", true),
557            // nested child matches
558            (&["dir1"], "dir1/sub/c.txt", true),
559            // unrelated dir does NOT match
560            (&["dir1"], "dir2/d.txt", false),
561            // "." matches everything recursively
562            (&["."], "a.txt", true),
563            (&["."], "dir1/b.txt", true),
564            (&["."], "dir1/sub/c.txt", true),
565            // "../foo" escapes root → dropped
566            (&["../foo"], "foo", false),
567            // "a/../../x" escapes root → dropped
568            (&["a/../../x"], "x", false),
569            // "dir1/../dir2" normalizes to "dir2", matches descendants
570            (&["dir1/../dir2"], "dir2/d.txt", true),
571        ];
572        run_filter_cases(cases, true);
573    }
574
575    #[test]
576    fn from_user_paths_with_subdirectory_cwd() {
577        let (_tmp, root) = create_temp_git_repo();
578        let (_config, _dvs_dir) = init_dvs_repo(&root);
579
580        // Create subdirectories so canonicalize works in DvsPaths::new
581        fs::create_dir_all(root.join("subdir/deep")).unwrap();
582        fs::create_dir_all(root.join("dir2")).unwrap();
583
584        // From subdir/: ../foo → resolves to "foo" (valid)
585        let paths = DvsPaths::new(
586            fs::canonicalize(root.join("subdir")).unwrap(),
587            root.to_path_buf(),
588            ".dvs",
589        )
590        .unwrap();
591        let filter = PathFilter::from_user_paths(vec![PathBuf::from("../foo")], false, &paths);
592        assert_eq!(filter.paths, vec![PathBuf::from("foo")]);
593
594        // From subdir/: ../../foo → escapes root → dropped (empty)
595        let filter = PathFilter::from_user_paths(vec![PathBuf::from("../../foo")], false, &paths);
596        assert!(
597            filter.paths.is_empty(),
598            "../../foo should escape root and be dropped"
599        );
600
601        // From subdir/deep/: ../../foo → resolves to "foo" (valid, 2 levels up = root)
602        let paths_deep = DvsPaths::new(
603            fs::canonicalize(root.join("subdir/deep")).unwrap(),
604            root.to_path_buf(),
605            ".dvs",
606        )
607        .unwrap();
608        let filter =
609            PathFilter::from_user_paths(vec![PathBuf::from("../../foo")], false, &paths_deep);
610        assert_eq!(filter.paths, vec![PathBuf::from("foo")]);
611
612        // From subdir/: ../dir2/file.txt → resolves to "dir2/file.txt"
613        let filter =
614            PathFilter::from_user_paths(vec![PathBuf::from("../dir2/file.txt")], false, &paths);
615        assert_eq!(filter.paths, vec![PathBuf::from("dir2/file.txt")]);
616
617        // From subdir/: absolute path with .. like <root>/subdir/../a.txt → normalizes to "a.txt"
618        let abs_with_dotdot = root.join("subdir/../a.txt");
619        let filter = PathFilter::from_user_paths(vec![abs_with_dotdot], false, &paths);
620        assert_eq!(filter.paths, vec![PathBuf::from("a.txt")]);
621    }
622}