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 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 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 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 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 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 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 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 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 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 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 fs::write(&file_b, b"foo").unwrap();
313
314 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 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 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 let cases: Vec<(&[&str], &str, bool)> = vec![
377 (&["dir1"], "dir1/b.txt", true),
379 (&["dir1"], "dir1/sub/c.txt", false),
381 (&["dir2/d.txt"], "dir2/d.txt", true),
383 (&["dir2/d.txt"], "dir2/e.txt", false),
385 (&["."], "a.txt", true),
387 (&["."], "dir1/b.txt", false),
389 (&["../foo"], "foo", false),
391 (&["dir1/../dir2"], "dir2/d.txt", true),
393 (&["dir1/.."], "a.txt", true),
395 ];
396 run_filter_cases(cases, false);
397 }
398
399 #[test]
400 fn status_filter_matches_recursive() {
401 let cases: Vec<(&[&str], &str, bool)> = vec![
403 (&["dir1"], "dir1/b.txt", true),
405 (&["dir1"], "dir1/sub/c.txt", true),
407 (&["dir1"], "dir2/d.txt", false),
409 (&["."], "a.txt", true),
411 (&["."], "dir1/b.txt", true),
412 (&["."], "dir1/sub/c.txt", true),
413 (&["../foo"], "foo", false),
415 (&["a/../../x"], "x", false),
417 (&["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 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 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 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 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 let sub =
467 DvsPaths::new(fs::canonicalize(root.join("dir1")).unwrap(), root, ".dvs").unwrap();
468
469 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 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 fs::create_dir_all(root.join("subdir/deep")).unwrap();
496 fs::create_dir_all(root.join("dir2")).unwrap();
497
498 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 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 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 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 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}