Skip to main content
← dvs documentation Rust API reference

dvs/
paths.rs

1use std::path::{Path, PathBuf};
2
3use crate::config::Config;
4use anyhow::Result;
5use fs_err as fs;
6
7pub const CONFIG_FILE_NAME: &str = "dvs.toml";
8pub const DEFAULT_FOLDER_NAME: &str = ".dvs";
9
10/// Result of validating a file path for `add`.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum AddPathStatus {
13    /// File exists and is inside the project
14    Valid,
15    /// File does not exist on disk
16    NotFound,
17    /// Path is a directory, not a file
18    IsDirectory,
19    /// Path resolves to outside the project root
20    OutsideProject,
21}
22
23/// Result of validating a file path for `get`.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum GetPathStatus {
26    /// Metadata exists: file is tracked
27    Tracked,
28    /// No metadata and no file on disk
29    NotFound,
30    /// File exists on disk but is not tracked by DVS
31    NotTracked,
32}
33
34/// Finds the root of a project by walking up from the given directory
35/// until a `dvs.toml` is found
36///
37/// Returns the `start_dir` if no `dvs.toml` has been found.
38pub fn find_repo_root(start_dir: impl AsRef<Path>) -> PathBuf {
39    let mut dir = start_dir.as_ref();
40    log::debug!("Searching for repo root starting from {}", dir.display());
41
42    loop {
43        if dir.join(CONFIG_FILE_NAME).exists() {
44            log::debug!("Found repo root at {}", dir.display());
45            return dir.to_path_buf();
46        }
47
48        if let Some(parent) = dir.parent() {
49            dir = parent;
50        } else {
51            break;
52        }
53    }
54
55    start_dir.as_ref().to_path_buf()
56}
57
58/// We always need to figure out where the user is in a project,
59/// where the root is etc.
60/// This struct handles all of it so the rest of the code doesn't have to
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct DvsPaths {
63    /// Canonicalized path of where the user currently is
64    cwd: PathBuf,
65    /// Canonicalized path where our `dvs.toml` is
66    repo_root: PathBuf,
67    /// Folder name for metadata, defined in the config
68    metadata_folder_name: String,
69}
70
71impl DvsPaths {
72    /// Create with explicit paths (for testing or R package)
73    pub fn new(
74        cwd: PathBuf,
75        repo_root: PathBuf,
76        metadata_folder_name: impl Into<String>,
77    ) -> Result<Self> {
78        Ok(Self {
79            cwd,
80            repo_root: fs::canonicalize(&repo_root)?,
81            metadata_folder_name: metadata_folder_name.into(),
82        })
83    }
84
85    pub fn from_cwd(config: &Config) -> Result<Self> {
86        let cwd = fs::canonicalize(std::env::current_dir()?)?;
87        let repo_root = fs::canonicalize(find_repo_root(&cwd))?;
88
89        log::debug!(
90            "Resolved paths: cwd={}, repo_root={}",
91            cwd.display(),
92            repo_root.display()
93        );
94        Ok(Self {
95            cwd,
96            repo_root,
97            metadata_folder_name: config.metadata_folder_name().to_owned(),
98        })
99    }
100
101    pub fn metadata_folder(&self) -> PathBuf {
102        self.repo_root.join(&self.metadata_folder_name)
103    }
104
105    pub fn cache_folder(&self) -> PathBuf {
106        self.metadata_folder().join(".cache")
107    }
108
109    pub fn metadata_path(&self, relative: &Path) -> PathBuf {
110        let dvs_path = self.metadata_folder().join(relative);
111        let mut s = dvs_path.into_os_string();
112        s.push(".dvs");
113        PathBuf::from(s)
114    }
115
116    pub fn repo_root(&self) -> &Path {
117        &self.repo_root
118    }
119
120    pub fn cwd(&self) -> &Path {
121        &self.cwd
122    }
123
124    /// Get the path relative from repo root to cwd, or None if at repo root
125    pub fn cwd_relative_to_root(&self) -> Option<&Path> {
126        self.cwd
127            .strip_prefix(&self.repo_root)
128            .ok()
129            .filter(|p| !p.as_os_str().is_empty())
130    }
131
132    /// Construct the full file path from a repo-relative path
133    pub fn file_path(&self, relative: &Path) -> PathBuf {
134        self.repo_root.join(relative)
135    }
136
137    pub fn validate_for_add(&self, paths: &[PathBuf]) -> Vec<(PathBuf, AddPathStatus)> {
138        let mut found = Vec::new();
139        for path in paths {
140            let file_path = self.file_path(path);
141            let status = match file_path.canonicalize() {
142                Ok(canonical) => {
143                    if !canonical.starts_with(&self.repo_root) {
144                        AddPathStatus::OutsideProject
145                    } else if canonical.is_dir() {
146                        AddPathStatus::IsDirectory
147                    } else if canonical.is_file() {
148                        AddPathStatus::Valid
149                    } else {
150                        AddPathStatus::NotFound
151                    }
152                }
153                Err(_) => AddPathStatus::NotFound,
154            };
155            found.push((path.clone(), status));
156        }
157        found
158    }
159
160    pub fn validate_for_get(&self, paths: &[PathBuf]) -> Vec<(PathBuf, GetPathStatus)> {
161        let mut found = Vec::new();
162        for path in paths {
163            let metadata_path = self.metadata_path(path);
164            let validation = if metadata_path.is_file() {
165                GetPathStatus::Tracked
166            } else if self.file_path(path).is_file() {
167                GetPathStatus::NotTracked
168            } else {
169                GetPathStatus::NotFound
170            };
171            found.push((path.clone(), validation));
172        }
173        found
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::testutil::create_temp_git_repo;
181
182    #[test]
183    fn metadata_path_returns_dvs_file_path() {
184        let (_tmp, root) = create_temp_git_repo();
185        let paths = DvsPaths::new(root.clone(), root.clone(), ".meta").unwrap();
186
187        let result = paths.metadata_path(Path::new("sub/file.txt"));
188        assert_eq!(result, root.join(".meta/sub/file.txt.dvs"));
189    }
190
191    #[test]
192    fn validate_for_add() {
193        let (_tmp, root) = create_temp_git_repo();
194        let paths = DvsPaths::new(root.clone(), root.clone(), ".dvs").unwrap();
195
196        // Valid: existing file inside repo
197        fs_err::write(root.join("test.txt"), b"content").unwrap();
198
199        // OutsideProject: file in a sibling temp dir
200        let outside_tmp = tempfile::tempdir().unwrap();
201        let outside_path = fs::canonicalize(outside_tmp.path()).unwrap();
202        fs_err::write(outside_path.join("outside.txt"), b"outside").unwrap();
203        let outside_relative = PathBuf::from("..").join(
204            outside_path
205                .join("outside.txt")
206                .strip_prefix(root.parent().unwrap())
207                .unwrap(),
208        );
209
210        // IsDirectory: a subdirectory inside the repo
211        fs_err::create_dir(root.join("subdir")).unwrap();
212
213        let result = paths.validate_for_add(&[
214            PathBuf::from("test.txt"),
215            PathBuf::from("nonexistent.txt"),
216            outside_relative,
217            PathBuf::from("subdir"),
218        ]);
219
220        assert_eq!(result[0].1, AddPathStatus::Valid);
221        assert_eq!(result[1].1, AddPathStatus::NotFound);
222        assert_eq!(result[2].1, AddPathStatus::OutsideProject);
223        assert_eq!(result[3].1, AddPathStatus::IsDirectory);
224    }
225}