Skip to main content
← dvs documentation Rust API reference

dvs/
paths.rs

1use std::ffi::OsStr;
2use std::path::{Component, Path, PathBuf};
3
4use anyhow::Result;
5use fs_err as fs;
6use globset::GlobMatcher;
7use serde::{Deserialize, Serialize};
8use walkdir::WalkDir;
9
10use crate::config::Config;
11
12pub const CONFIG_FILE_NAME: &str = "dvs.toml";
13pub const DEFAULT_FOLDER_NAME: &str = ".dvs";
14
15/// Result of validating a file path for `add`.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum AddPathStatus {
18    /// File exists and is inside the project
19    Valid,
20    /// File does not exist on disk
21    NotFound,
22    /// Path is a directory, not a file
23    IsDirectory,
24    /// Path resolves to outside the project root
25    OutsideProject,
26}
27
28impl AddPathStatus {
29    pub fn reason(&self) -> Option<&str> {
30        match self {
31            AddPathStatus::Valid => None,
32            AddPathStatus::NotFound => Some("file not found"),
33            AddPathStatus::OutsideProject => Some("path is outside project"),
34            AddPathStatus::IsDirectory => Some("path is a directory"),
35        }
36    }
37}
38
39/// Result of validating a file path for `get`.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum GetPathStatus {
42    /// Metadata exists: file is tracked
43    Tracked,
44    /// No metadata and no file on disk
45    NotFound,
46    /// File exists on disk but is not tracked by DVS
47    NotTracked,
48}
49
50impl GetPathStatus {
51    pub fn reason(&self) -> Option<&str> {
52        match self {
53            GetPathStatus::Tracked => None,
54            GetPathStatus::NotFound => Some("file not found"),
55            GetPathStatus::NotTracked => Some("not tracked by DVS"),
56        }
57    }
58}
59
60/// Finds the root of a project by walking up from the given directory
61/// until a `dvs.toml` is found
62///
63/// Returns the `start_dir` if no `dvs.toml` has been found.
64pub fn find_repo_root(start_dir: impl AsRef<Path>) -> PathBuf {
65    let mut dir = start_dir.as_ref();
66    log::debug!("Searching for repo root starting from {}", dir.display());
67
68    loop {
69        if dir.join(CONFIG_FILE_NAME).exists() {
70            log::debug!("Found repo root at {}", dir.display());
71            return dir.to_path_buf();
72        }
73
74        if let Some(parent) = dir.parent() {
75            dir = parent;
76        } else {
77            break;
78        }
79    }
80
81    start_dir.as_ref().to_path_buf()
82}
83
84/// Normalize a path by resolving `.` and `..` components lexically (no
85/// canonicalization — the path may not exist and we want it relative to the
86/// directory, with no symlink resolution). Returns `None` if `..` escapes the
87/// base (pops past the root). Shared by `globbing` and `status` path filtering.
88pub(crate) fn normalize_path(p: PathBuf) -> Option<PathBuf> {
89    let mut out = PathBuf::new();
90    for c in p.components() {
91        match c {
92            Component::CurDir => {}
93            Component::ParentDir => {
94                if !out.pop() {
95                    return None;
96                }
97            }
98            _ => out.push(c),
99        }
100    }
101    Some(out)
102}
103
104/// We always need to figure out where the user is in a project,
105/// where the root is etc.
106/// This struct handles all of it so the rest of the code doesn't have to
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct DvsPaths {
109    /// Canonicalized path of where the user currently is
110    cwd: PathBuf,
111    /// Canonicalized path where our `dvs.toml` is
112    repo_root: PathBuf,
113    /// Folder name for metadata, defined in the config
114    metadata_folder_name: String,
115}
116
117impl DvsPaths {
118    /// Create with explicit paths (for testing or R package)
119    pub fn new(
120        cwd: PathBuf,
121        repo_root: PathBuf,
122        metadata_folder_name: impl Into<String>,
123    ) -> Result<Self> {
124        Ok(Self {
125            cwd,
126            repo_root: fs::canonicalize(&repo_root)?,
127            metadata_folder_name: metadata_folder_name.into(),
128        })
129    }
130
131    pub fn from_cwd(config: &Config) -> Result<Self> {
132        let cwd = fs::canonicalize(std::env::current_dir()?)?;
133        let repo_root = fs::canonicalize(find_repo_root(&cwd))?;
134
135        log::debug!(
136            "Resolved paths: cwd={}, repo_root={}",
137            cwd.display(),
138            repo_root.display()
139        );
140        Ok(Self {
141            cwd,
142            repo_root,
143            metadata_folder_name: config.metadata_folder_name().to_owned(),
144        })
145    }
146
147    pub fn metadata_folder(&self) -> PathBuf {
148        self.repo_root.join(&self.metadata_folder_name)
149    }
150
151    pub fn cache_folder(&self) -> PathBuf {
152        self.metadata_folder().join(".cache")
153    }
154
155    pub fn metadata_path(&self, relative: &Path) -> PathBuf {
156        let dvs_path = self.metadata_folder().join(relative);
157        let mut s = dvs_path.into_os_string();
158        s.push(".dvs");
159        PathBuf::from(s)
160    }
161
162    /// Repo-root-relative paths of every tracked file: one per `.dvs` entry in
163    /// the metadata folder, with the `.dvs` extension stripped.
164    /// Shared by `get` and `status`
165    pub(crate) fn tracked_paths(&self) -> Vec<PathBuf> {
166        let metadata_root = self.metadata_folder();
167        WalkDir::new(&metadata_root)
168            .into_iter()
169            .filter_map(|e| e.ok())
170            .filter_map(|entry| {
171                let entry_path = entry.path();
172                if !entry_path.is_file() || entry_path.extension() != Some(OsStr::new("dvs")) {
173                    return None;
174                }
175                entry_path
176                    .strip_prefix(&metadata_root)
177                    .ok()
178                    .map(|rel| rel.with_extension(""))
179            })
180            .collect()
181    }
182
183    pub fn repo_root(&self) -> &Path {
184        &self.repo_root
185    }
186
187    pub fn cwd(&self) -> &Path {
188        &self.cwd
189    }
190
191    /// Get the path relative from repo root to cwd, or None if at repo root
192    pub fn cwd_relative_to_root(&self) -> Option<&Path> {
193        self.cwd
194            .strip_prefix(&self.repo_root)
195            .ok()
196            .filter(|p| !p.as_os_str().is_empty())
197    }
198
199    /// Construct the full file path from a repo-relative path
200    pub fn file_path(&self, relative: &Path) -> PathBuf {
201        self.repo_root.join(relative)
202    }
203
204    pub fn validate_for_add(&self, paths: &[PathBuf]) -> Vec<(PathBuf, AddPathStatus)> {
205        let mut found = Vec::new();
206        for path in paths {
207            let file_path = self.file_path(path);
208            let status = match file_path.canonicalize() {
209                Ok(canonical) => {
210                    if !canonical.starts_with(&self.repo_root) {
211                        AddPathStatus::OutsideProject
212                    } else if canonical.is_dir() {
213                        AddPathStatus::IsDirectory
214                    } else if canonical.is_file() {
215                        AddPathStatus::Valid
216                    } else {
217                        AddPathStatus::NotFound
218                    }
219                }
220                Err(_) => AddPathStatus::NotFound,
221            };
222            found.push((path.clone(), status));
223        }
224        found
225    }
226
227    pub fn validate_for_get(&self, paths: &[PathBuf]) -> Vec<(PathBuf, GetPathStatus)> {
228        let mut found = Vec::new();
229        for path in paths {
230            let metadata_path = self.metadata_path(path);
231            let validation = if metadata_path.is_file() {
232                GetPathStatus::Tracked
233            } else if self.file_path(path).is_file() {
234                GetPathStatus::NotTracked
235            } else {
236                GetPathStatus::NotFound
237            };
238            found.push((path.clone(), validation));
239        }
240        found
241    }
242}
243
244/// Which paths to get use for status/get
245/// eg you can pass dir1/ dir2/ and it will expand to dir1/* dir2/*
246/// If `recursive` is `true`, then it will expand to dir1/**/* dir2/**/*
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct PathFilter {
249    pub(crate) paths: Vec<PathBuf>,
250    pub(crate) recursive: bool,
251}
252
253impl PathFilter {
254    /// When a user doesn't provide a path, we default to the cwd relative to the root
255    pub fn cwd_scoped(recursive: bool, dvs_paths: &DvsPaths) -> Self {
256        let cwd = dvs_paths
257            .cwd_relative_to_root()
258            .map(Path::to_path_buf)
259            .unwrap_or_default(); // "" when at repo root
260        Self {
261            paths: vec![cwd],
262            recursive,
263        }
264    }
265
266    /// Create a filter from user-provided paths (relative to cwd) and a recursive flag.
267    /// Translates cwd-relative paths to repo-root-relative using `dvs_paths.cwd_relative_to_root()`.
268    pub fn from_user_paths(
269        user_paths: Vec<PathBuf>,
270        recursive: bool,
271        dvs_paths: &DvsPaths,
272    ) -> Self {
273        let cwd_prefix = dvs_paths.cwd_relative_to_root();
274        let repo_root = dvs_paths.repo_root();
275        let paths = user_paths
276            .into_iter()
277            .filter_map(|p| {
278                if p.is_absolute() {
279                    p.strip_prefix(repo_root)
280                        .ok()
281                        .map(|r| r.to_path_buf())
282                        .and_then(normalize_path)
283                } else {
284                    let joined = if let Some(prefix) = cwd_prefix {
285                        prefix.join(&p)
286                    } else {
287                        p
288                    };
289                    normalize_path(joined)
290                }
291            })
292            .collect();
293        Self { paths, recursive }
294    }
295
296    pub(crate) fn matches(&self, tracked_path: &Path, glob: Option<&GlobMatcher>) -> bool {
297        self.paths.iter().any(|filter_path| {
298            if tracked_path == filter_path {
299                return true;
300            }
301            // it needs to be a parent
302            let Ok(rel) = tracked_path.strip_prefix(filter_path) else {
303                return false;
304            };
305            match glob {
306                Some(g) => g.is_match(rel),
307                None => {
308                    // Recursive: any descendant or non-recursive: direct child
309                    self.recursive || tracked_path.parent() == Some(filter_path.as_path())
310                }
311            }
312        })
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::testutil::create_temp_git_repo;
320
321    #[test]
322    fn metadata_path_returns_dvs_file_path() {
323        let (_tmp, root) = create_temp_git_repo();
324        let paths = DvsPaths::new(root.clone(), root.clone(), ".meta").unwrap();
325
326        let result = paths.metadata_path(Path::new("sub/file.txt"));
327        assert_eq!(result, root.join(".meta/sub/file.txt.dvs"));
328    }
329
330    #[test]
331    fn validate_for_add() {
332        let (_tmp, root) = create_temp_git_repo();
333        let paths = DvsPaths::new(root.clone(), root.clone(), ".dvs").unwrap();
334
335        // Valid: existing file inside repo
336        fs_err::write(root.join("test.txt"), b"content").unwrap();
337
338        // OutsideProject: a sibling of the repo, outside its root.
339        fs_err::write(root.parent().unwrap().join("outside.txt"), b"outside").unwrap();
340        let outside_relative = PathBuf::from("..").join("outside.txt");
341
342        // IsDirectory: a subdirectory inside the repo
343        fs_err::create_dir(root.join("subdir")).unwrap();
344
345        let result = paths.validate_for_add(&[
346            PathBuf::from("test.txt"),
347            PathBuf::from("nonexistent.txt"),
348            outside_relative,
349            PathBuf::from("subdir"),
350        ]);
351
352        assert_eq!(result[0].1, AddPathStatus::Valid);
353        assert_eq!(result[1].1, AddPathStatus::NotFound);
354        assert_eq!(result[2].1, AddPathStatus::OutsideProject);
355        assert_eq!(result[3].1, AddPathStatus::IsDirectory);
356    }
357}