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#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum AddPathStatus {
18 Valid,
20 NotFound,
22 IsDirectory,
24 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum GetPathStatus {
42 Tracked,
44 NotFound,
46 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
60pub 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
84pub(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#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct DvsPaths {
109 cwd: PathBuf,
111 repo_root: PathBuf,
113 metadata_folder_name: String,
115}
116
117impl DvsPaths {
118 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 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 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 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#[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 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(); Self {
261 paths: vec![cwd],
262 recursive,
263 }
264 }
265
266 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 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 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 fs_err::write(root.join("test.txt"), b"content").unwrap();
337
338 fs_err::write(root.parent().unwrap().join("outside.txt"), b"outside").unwrap();
340 let outside_relative = PathBuf::from("..").join("outside.txt");
341
342 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}