Skip to main content
← dvs documentation Rust API reference

dvs/files/
status.rs

1use std::path::{Path, PathBuf};
2use std::sync::Mutex;
3
4use anyhow::Result;
5use fs_err as fs;
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8
9use crate::cache::{HashCache, try_open_cache};
10use crate::files::metadata::FileMetadata;
11use crate::paths::{DvsPaths, PathFilter};
12use crate::utils::get_threadpool;
13use crate::{Status, cache};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct FileStatus {
17    pub path: PathBuf,
18    #[serde(flatten)]
19    pub detail: StatusDetail,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23#[serde(untagged)]
24pub enum StatusDetail {
25    Success {
26        status: Status,
27        #[serde(skip_serializing_if = "Option::is_none")]
28        metadata: Option<FileMetadata>,
29    },
30    Error {
31        error: String,
32    },
33}
34
35fn get_file_status(
36    paths: &DvsPaths,
37    relative_path: impl AsRef<Path>,
38    cache: Option<&Mutex<HashCache>>,
39) -> Result<(Status, Option<FileMetadata>)> {
40    let dvs_file_path = paths.metadata_path(relative_path.as_ref());
41    if !dvs_file_path.is_file() {
42        return Ok((Status::Untracked, None));
43    }
44    let existing_metadata: FileMetadata = serde_json::from_reader(fs::File::open(dvs_file_path)?)?;
45    // If we have read the metadata, but we can't find the original file
46    let file_path = paths.file_path(relative_path.as_ref());
47    if !file_path.is_file() {
48        return Ok((Status::Absent, Some(existing_metadata)));
49    }
50    let rel_str = relative_path.as_ref().to_string_lossy();
51    let (hashes, size) = cache::hashes_for_file(&file_path, &rel_str, cache)?;
52
53    if existing_metadata.hashes == hashes && existing_metadata.size == size {
54        Ok((Status::Current, Some(existing_metadata)))
55    } else {
56        Ok((Status::Unsynced, Some(existing_metadata)))
57    }
58}
59
60pub fn get_status(
61    paths: &DvsPaths,
62    filter: Option<&PathFilter>,
63    glob_pattern: Option<&str>,
64) -> Result<Vec<FileStatus>> {
65    let dvs_directory = paths.metadata_folder();
66    log::debug!("Scanning metadata folder: {}", dvs_directory.display());
67    let cache = try_open_cache(paths);
68    let glob = crate::globbing::build_glob_matcher(glob_pattern)?;
69
70    let entries = paths.tracked_paths();
71
72    if entries.is_empty() {
73        return Ok(Vec::new());
74    }
75
76    // A bare glob with no explicit paths anchors to the cwd, matching `add` and
77    // `get` (specs.md Globbing section). Without this the glob matched against
78    // the full repo-relative path, so from a subdirectory `status --glob` globbed
79    // the repo root while `get --glob` globbed the cwd.
80    let cwd_filter = match (filter, glob_pattern) {
81        (None, Some(_)) => Some(PathFilter::cwd_scoped(false, paths)),
82        _ => None,
83    };
84    let filter = filter.or(cwd_filter.as_ref());
85
86    let pool = get_threadpool(entries.len())?;
87
88    let mut results: Vec<FileStatus> = pool.install(|| {
89        entries
90            .into_par_iter()
91            .filter_map(|relative| {
92                // With a filter the glob is anchored to the matched path. With no
93                // filter and no glob, every tracked file is kept.
94                let keep = match filter {
95                    Some(f) => f.matches(&relative, glob.as_ref()),
96                    None => true,
97                };
98                if !keep {
99                    return None;
100                }
101                let detail = match get_file_status(paths, &relative, cache.as_ref()) {
102                    Ok((status, file_metadata)) => StatusDetail::Success {
103                        status,
104                        metadata: file_metadata,
105                    },
106                    Err(e) => StatusDetail::Error {
107                        error: e.to_string(),
108                    },
109                };
110                Some(FileStatus {
111                    path: relative,
112                    detail,
113                })
114            })
115            .collect()
116    });
117    results.sort_by(|a, b| a.path.cmp(&b.path));
118
119    log::debug!("Found {} tracked files", results.len());
120    Ok(results)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::Compression;
127    use crate::paths::normalize_path;
128    use crate::testutil::{create_file, create_temp_git_repo, init_dvs_repo};
129    use uuid::Uuid;
130
131    fn make_paths(root: &Path, config: &crate::config::Config) -> DvsPaths {
132        DvsPaths::new(
133            root.to_path_buf(),
134            root.to_path_buf(),
135            config.metadata_folder_name(),
136        )
137        .unwrap()
138    }
139
140    fn make_cache(paths: &DvsPaths) -> Mutex<HashCache> {
141        Mutex::new(HashCache::open(&paths.cache_folder().join("dvs.db")).unwrap())
142    }
143
144    #[test]
145    fn get_file_status_returns_untracked_for_new_file() {
146        let (_tmp, root) = create_temp_git_repo();
147        let (config, _dvs_dir) = init_dvs_repo(&root);
148        let paths = make_paths(&root, &config);
149        create_file(&root, "new.txt", b"content");
150
151        let cache = make_cache(&paths);
152        let (status, metadata) = get_file_status(&paths, "new.txt", Some(&cache)).unwrap();
153        assert_eq!(status, Status::Untracked);
154        assert!(metadata.is_none());
155    }
156
157    #[test]
158    fn get_file_status_returns_current_for_synced_file() {
159        let (_tmp, root) = create_temp_git_repo();
160        let (config, _dvs_dir) = init_dvs_repo(&root);
161        let backend = config.backend();
162        let paths = make_paths(&root, &config);
163        let file_path = create_file(&root, "synced.txt", b"content");
164
165        let metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
166        metadata
167            .save(
168                Uuid::new_v4(),
169                &file_path,
170                backend,
171                &paths,
172                "synced.txt",
173                None,
174            )
175            .unwrap();
176
177        let cache = make_cache(&paths);
178        let (status, metadata) = get_file_status(&paths, "synced.txt", Some(&cache)).unwrap();
179        assert_eq!(status, Status::Current);
180        assert!(metadata.is_some());
181    }
182
183    #[test]
184    fn get_file_status_returns_absent_when_file_deleted() {
185        let (_tmp, root) = create_temp_git_repo();
186        let (config, _dvs_dir) = init_dvs_repo(&root);
187        let backend = config.backend();
188        let paths = make_paths(&root, &config);
189        let file_path = create_file(&root, "deleted.txt", b"content");
190
191        let metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
192        metadata
193            .save(
194                Uuid::new_v4(),
195                &file_path,
196                backend,
197                &paths,
198                "deleted.txt",
199                None,
200            )
201            .unwrap();
202
203        // Delete the original file
204        fs::remove_file(&file_path).unwrap();
205
206        let cache = make_cache(&paths);
207        let (status, metadata) = get_file_status(&paths, "deleted.txt", Some(&cache)).unwrap();
208        assert_eq!(status, Status::Absent);
209        assert!(metadata.is_some());
210    }
211
212    #[test]
213    fn get_file_status_returns_unsynced_when_file_modified() {
214        let (_tmp, root) = create_temp_git_repo();
215        let (config, _dvs_dir) = init_dvs_repo(&root);
216        let backend = config.backend();
217        let paths = make_paths(&root, &config);
218        let file_path = create_file(&root, "modified.txt", b"original");
219
220        let metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
221        metadata
222            .save(
223                Uuid::new_v4(),
224                &file_path,
225                backend,
226                &paths,
227                "modified.txt",
228                None,
229            )
230            .unwrap();
231
232        // Modify the file
233        fs::write(&file_path, b"changed content").unwrap();
234
235        let cache = make_cache(&paths);
236        let (status, metadata) = get_file_status(&paths, "modified.txt", Some(&cache)).unwrap();
237        assert_eq!(status, Status::Unsynced);
238        assert!(metadata.is_some());
239    }
240
241    #[test]
242    fn get_status_returns_all_tracked_files() {
243        let (_tmp, root) = create_temp_git_repo();
244        let (config, _dvs_dir) = init_dvs_repo(&root);
245        let backend = config.backend();
246        let paths = make_paths(&root, &config);
247
248        // Add multiple files
249        for name in ["a.txt", "b.txt", "subdir/c.txt"] {
250            let file_path = create_file(&root, name, name.as_bytes());
251            let metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
252            metadata
253                .save(Uuid::new_v4(), &file_path, backend, &paths, name, None)
254                .unwrap();
255        }
256
257        let statuses = get_status(&paths, None, None).unwrap();
258        assert_eq!(statuses.len(), 3);
259
260        // All should be Current
261        for status in &statuses {
262            match &status.detail {
263                StatusDetail::Success { status, metadata } => {
264                    assert_eq!(*status, Status::Current);
265                    assert!(metadata.is_some());
266                }
267                StatusDetail::Error { error } => panic!("unexpected error: {error}"),
268            }
269        }
270    }
271
272    #[test]
273    fn get_status_returns_empty_vec_for_repo_with_no_tracked_files() {
274        let (_tmp, root) = create_temp_git_repo();
275        let (config, _dvs_dir) = init_dvs_repo(&root);
276        let paths = make_paths(&root, &config);
277
278        let statuses = get_status(&paths, None, None).unwrap();
279        assert!(statuses.is_empty());
280    }
281
282    #[test]
283    fn save_local_updates_metadata_when_content_matches_different_file() {
284        // - Add file A with content "foo" (hash H1)
285        // - Add file B with content "bar" (hash H2)
286        // - Change file B's content to "foo" (now hash H1)
287        // - Run `add` on B
288        // => B's metadata is updated to hash H1
289        let (_tmp, root) = create_temp_git_repo();
290        let (config, dvs_dir) = init_dvs_repo(&root);
291        let backend = config.backend();
292        let paths = make_paths(&root, &config);
293
294        // Add file A with content "foo" (hash H1)
295        let file_a = create_file(&root, "a.txt", b"foo");
296        let metadata_a = FileMetadata::from_file(&file_a, Compression::Zstd, None).unwrap();
297        metadata_a
298            .save(Uuid::new_v4(), &file_a, backend, &paths, "a.txt", None)
299            .unwrap();
300        let hash_h1 = metadata_a.hashes.blake3.clone();
301
302        // Add file B with content "bar" (hash H2)
303        let file_b = create_file(&root, "b.txt", b"bar");
304        let metadata_b = FileMetadata::from_file(&file_b, Compression::Zstd, None).unwrap();
305        metadata_b
306            .save(Uuid::new_v4(), &file_b, backend, &paths, "b.txt", None)
307            .unwrap();
308        let hash_h2 = metadata_b.hashes.blake3.clone();
309        assert_ne!(hash_h1, hash_h2);
310
311        // Change file B's content to "foo" (now hash H1)
312        fs::write(&file_b, b"foo").unwrap();
313
314        // Run add on B with new content
315        let metadata_b_new = FileMetadata::from_file(&file_b, Compression::Zstd, None).unwrap();
316        assert_eq!(metadata_b_new.hashes.blake3, hash_h1);
317
318        metadata_b_new
319            .save(Uuid::new_v4(), &file_b, backend, &paths, "b.txt", None)
320            .unwrap();
321
322        // Verify metadata was updated
323        let dvs_file = dvs_dir.join("b.txt.dvs");
324        let stored: FileMetadata =
325            serde_json::from_reader(fs::File::open(&dvs_file).unwrap()).unwrap();
326
327        assert_eq!(
328            stored.hashes.blake3, hash_h1,
329            "Metadata should be updated to new hash"
330        );
331
332        let cache = make_cache(&paths);
333        let (status, _metadata) = get_file_status(&paths, "b.txt", Some(&cache)).unwrap();
334        assert_eq!(status, Status::Current);
335    }
336
337    /// Helper to set up a repo with files at various directory depths.
338    /// Returns (TempDir, DvsPaths) with tracked files:
339    ///   "a.txt", "dir1/b.txt", "dir1/sub/c.txt", "dir2/d.txt"
340    fn setup_filtered_repo() -> (tempfile::TempDir, DvsPaths) {
341        let (tmp, root) = create_temp_git_repo();
342        let (config, _dvs_dir) = init_dvs_repo(&root);
343        let backend = config.backend();
344        let paths = make_paths(&root, &config);
345
346        for name in ["a.txt", "dir1/b.txt", "dir1/sub/c.txt", "dir2/d.txt"] {
347            let file_path = create_file(&root, name, name.as_bytes());
348            let metadata = FileMetadata::from_file(&file_path, Compression::Zstd, None).unwrap();
349            metadata
350                .save(Uuid::new_v4(), &file_path, backend, &paths, name, None)
351                .unwrap();
352        }
353        (tmp, paths)
354    }
355
356    fn run_filter_cases(cases: Vec<(&[&str], &str, bool)>, recursive: bool) {
357        for (filter_paths, test_path, expected) in cases {
358            let filter = PathFilter {
359                paths: filter_paths
360                    .iter()
361                    .filter_map(|p| normalize_path(PathBuf::from(p)))
362                    .collect(),
363                recursive,
364            };
365            assert_eq!(
366                filter.matches(Path::new(test_path), None),
367                expected,
368                "filter={filter_paths:?} recursive={recursive} path={test_path:?}"
369            );
370        }
371    }
372
373    #[test]
374    fn status_filter_matches_non_recursive() {
375        // (filter_paths, test_path, expected)
376        let cases: Vec<(&[&str], &str, bool)> = vec![
377            // direct child matches
378            (&["dir1"], "dir1/b.txt", true),
379            // nested child does NOT match
380            (&["dir1"], "dir1/sub/c.txt", false),
381            // exact file match
382            (&["dir2/d.txt"], "dir2/d.txt", true),
383            // exact file: different file does NOT match
384            (&["dir2/d.txt"], "dir2/e.txt", false),
385            // "." matches root-level files
386            (&["."], "a.txt", true),
387            // "." does NOT match nested files
388            (&["."], "dir1/b.txt", false),
389            // "../foo" escapes root → dropped, matches nothing
390            (&["../foo"], "foo", false),
391            // "dir1/../dir2" normalizes to "dir2", matches direct child
392            (&["dir1/../dir2"], "dir2/d.txt", true),
393            // "dir1/.." normalizes to root, matches root-level files
394            (&["dir1/.."], "a.txt", true),
395        ];
396        run_filter_cases(cases, false);
397    }
398
399    #[test]
400    fn status_filter_matches_recursive() {
401        // (filter_paths, test_path, expected)
402        let cases: Vec<(&[&str], &str, bool)> = vec![
403            // direct child matches
404            (&["dir1"], "dir1/b.txt", true),
405            // nested child matches
406            (&["dir1"], "dir1/sub/c.txt", true),
407            // unrelated dir does NOT match
408            (&["dir1"], "dir2/d.txt", false),
409            // "." matches everything recursively
410            (&["."], "a.txt", true),
411            (&["."], "dir1/b.txt", true),
412            (&["."], "dir1/sub/c.txt", true),
413            // "../foo" escapes root → dropped
414            (&["../foo"], "foo", false),
415            // "a/../../x" escapes root → dropped
416            (&["a/../../x"], "x", false),
417            // "dir1/../dir2" normalizes to "dir2", matches descendants
418            (&["dir1/../dir2"], "dir2/d.txt", true),
419        ];
420        run_filter_cases(cases, true);
421    }
422
423    #[test]
424    fn get_status_with_filter() {
425        let (_tmp, paths) = setup_filtered_repo();
426
427        // Relative path filter
428        let filter = PathFilter {
429            paths: vec![PathBuf::from("dir1")],
430            recursive: false,
431        };
432        let statuses = get_status(&paths, Some(&filter), None).unwrap();
433        assert_eq!(statuses.len(), 1);
434        assert_eq!(statuses[0].path, PathBuf::from("dir1/b.txt"));
435
436        // Absolute path filter via from_user_paths
437        let abs_path = paths.repo_root().join("dir1/b.txt");
438        let filter = PathFilter::from_user_paths(vec![abs_path], false, &paths);
439        let statuses = get_status(&paths, Some(&filter), None).unwrap();
440        assert_eq!(statuses.len(), 1);
441        assert_eq!(statuses[0].path, PathBuf::from("dir1/b.txt"));
442    }
443
444    #[test]
445    fn get_status_whole_repo_applies_glob() {
446        let (_tmp, paths) = setup_filtered_repo();
447
448        // No filter (whole repo) + glob: the glob still applies, matched against
449        // the full repo-relative path. `*.txt` (literal separator) hits only
450        // root-level files.
451        let statuses = get_status(&paths, None, Some("*.txt")).unwrap();
452        assert_eq!(statuses.len(), 1);
453        assert_eq!(statuses[0].path, PathBuf::from("a.txt"));
454
455        // `**/*.txt` reaches every tracked file.
456        let statuses = get_status(&paths, None, Some("**/*.txt")).unwrap();
457        assert_eq!(statuses.len(), 4);
458    }
459
460    #[test]
461    fn status_bare_glob_anchors_to_cwd_subdir() {
462        let (_tmp, paths) = setup_filtered_repo();
463        let root = paths.repo_root().to_path_buf();
464        // cwd = dir1: a bare glob must scope to the cwd (like add/get), not the
465        // repo root. Pre-fix it globbed the whole repo from any directory.
466        let sub =
467            DvsPaths::new(fs::canonicalize(root.join("dir1")).unwrap(), root, ".dvs").unwrap();
468
469        // Literal separator: only the direct child dir1/b.txt, not a.txt/dir2.
470        let got: Vec<_> = get_status(&sub, None, Some("*.txt"))
471            .unwrap()
472            .into_iter()
473            .map(|f| f.path)
474            .collect();
475        assert_eq!(got, vec![PathBuf::from("dir1/b.txt")]);
476
477        // Recursive glob reaches dir1/sub/c.txt, still scoped under dir1.
478        let got: Vec<_> = get_status(&sub, None, Some("**/*.txt"))
479            .unwrap()
480            .into_iter()
481            .map(|f| f.path)
482            .collect();
483        assert_eq!(
484            got,
485            vec![PathBuf::from("dir1/b.txt"), PathBuf::from("dir1/sub/c.txt")]
486        );
487    }
488
489    #[test]
490    fn from_user_paths_with_subdirectory_cwd() {
491        let (_tmp, root) = create_temp_git_repo();
492        let (_config, _dvs_dir) = init_dvs_repo(&root);
493
494        // Create subdirectories so canonicalize works in DvsPaths::new
495        fs::create_dir_all(root.join("subdir/deep")).unwrap();
496        fs::create_dir_all(root.join("dir2")).unwrap();
497
498        // From subdir/: ../foo → resolves to "foo" (valid)
499        let paths = DvsPaths::new(
500            fs::canonicalize(root.join("subdir")).unwrap(),
501            root.to_path_buf(),
502            ".dvs",
503        )
504        .unwrap();
505        let filter = PathFilter::from_user_paths(vec![PathBuf::from("../foo")], false, &paths);
506        assert_eq!(filter.paths, vec![PathBuf::from("foo")]);
507
508        // From subdir/: ../../foo → escapes root → dropped (empty)
509        let filter = PathFilter::from_user_paths(vec![PathBuf::from("../../foo")], false, &paths);
510        assert!(
511            filter.paths.is_empty(),
512            "../../foo should escape root and be dropped"
513        );
514
515        // From subdir/deep/: ../../foo → resolves to "foo" (valid, 2 levels up = root)
516        let paths_deep = DvsPaths::new(
517            fs::canonicalize(root.join("subdir/deep")).unwrap(),
518            root.to_path_buf(),
519            ".dvs",
520        )
521        .unwrap();
522        let filter =
523            PathFilter::from_user_paths(vec![PathBuf::from("../../foo")], false, &paths_deep);
524        assert_eq!(filter.paths, vec![PathBuf::from("foo")]);
525
526        // From subdir/: ../dir2/file.txt → resolves to "dir2/file.txt"
527        let filter =
528            PathFilter::from_user_paths(vec![PathBuf::from("../dir2/file.txt")], false, &paths);
529        assert_eq!(filter.paths, vec![PathBuf::from("dir2/file.txt")]);
530
531        // From subdir/: absolute path with .. like <root>/subdir/../a.txt → normalizes to "a.txt"
532        let abs_with_dotdot = root.join("subdir/../a.txt");
533        let filter = PathFilter::from_user_paths(vec![abs_with_dotdot], false, &paths);
534        assert_eq!(filter.paths, vec![PathBuf::from("a.txt")]);
535    }
536}