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#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum AddPathStatus {
20 Valid,
22 NotFound,
24 IsDirectory,
26 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#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum GetPathStatus {
44 Tracked,
46 NotFound,
48 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
62pub 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
86pub(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#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct DvsPaths {
111 cwd: PathBuf,
113 repo_root: PathBuf,
115 metadata_folder_name: String,
117}
118
119impl DvsPaths {
120 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 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 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 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#[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 pub fn paths(&self) -> &[PathBuf] {
258 &self.paths
259 }
260
261 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(); Self {
268 paths: vec![cwd],
269 recursive,
270 }
271 }
272
273 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 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 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#[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 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 fs_err::write(root.join("test.txt"), b"content").unwrap();
438
439 fs_err::write(root.parent().unwrap().join("outside.txt"), b"outside").unwrap();
441 let outside_relative = PathBuf::from("..").join("outside.txt");
442
443 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 let cases: Vec<(&[&str], &str, bool)> = vec![
529 (&["dir1"], "dir1/b.txt", true),
531 (&["dir1"], "dir1/sub/c.txt", false),
533 (&["dir2/d.txt"], "dir2/d.txt", true),
535 (&["dir2/d.txt"], "dir2/e.txt", false),
537 (&["."], "a.txt", true),
539 (&["."], "dir1/b.txt", false),
541 (&["../foo"], "foo", false),
543 (&["dir1/../dir2"], "dir2/d.txt", true),
545 (&["dir1/.."], "a.txt", true),
547 ];
548 run_filter_cases(cases, false);
549 }
550
551 #[test]
552 fn status_filter_matches_recursive() {
553 let cases: Vec<(&[&str], &str, bool)> = vec![
555 (&["dir1"], "dir1/b.txt", true),
557 (&["dir1"], "dir1/sub/c.txt", true),
559 (&["dir1"], "dir2/d.txt", false),
561 (&["."], "a.txt", true),
563 (&["."], "dir1/b.txt", true),
564 (&["."], "dir1/sub/c.txt", true),
565 (&["../foo"], "foo", false),
567 (&["a/../../x"], "x", false),
569 (&["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 fs::create_dir_all(root.join("subdir/deep")).unwrap();
582 fs::create_dir_all(root.join("dir2")).unwrap();
583
584 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 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 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 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 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}