Skip to main content
← dvs documentation Rust API reference

dvs/files/
get.rs

1//! `get`: restore tracked files from storage without losing local work.
2//!
3//! Every requested file is classified first, locally and without writing
4//! anything (the plan). A working file that differs from its target is only
5//! replaced without asking when it equals the baseline, the bytes dvs itself
6//! last wrote or added there; anything else is a conflict, handled by the
7//! `on_conflict` policy. Remote and runtime problems are per file and never stop
8//! the rest of the batch.
9
10use std::io;
11use std::path::{Component, Path, PathBuf};
12use std::sync::Mutex;
13use std::time::{Duration, Instant};
14
15use crate::cache::{FileStat, HashCache, baseline_for, record_baseline, try_open_cache};
16use crate::config::{OnConflict, SettingSource};
17use crate::error::{Error, ErrorCode, backend_error_code};
18use crate::files::metadata::FileMetadata;
19use crate::globbing::GetSelection;
20use crate::operation::{
21    ByteTotals, FileRow, Hashing, OperationContext, OperationPlan, RunOutcome, millis,
22};
23use crate::paths::ProjectPath;
24use crate::utils::get_threadpool;
25use crate::{Backend, Compression, DvsPaths, RetrieveRequest, cache};
26use fs_err as fs;
27use rayon::prelude::*;
28use serde::Serialize;
29use uuid::Uuid;
30
31/// The state of a requested file before `get` touches it.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Before {
35    /// No working file
36    Absent,
37    /// The working file is the target
38    Current,
39    /// Differs from the target but is exactly what dvs last wrote or added here
40    Clean,
41    /// Differs from both the target and what dvs last wrote or added here
42    Modified,
43    /// Differs from the target, and dvs has no record of writing it
44    Unknown,
45    /// No `.dvs` file for the requested path
46    NotTracked,
47    /// The destination is a directory, a symlink or another non-regular file
48    NotAFile,
49    /// The destination resolves outside the project
50    UnsafePath,
51}
52
53impl Before {
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            Before::Absent => "absent",
57            Before::Current => "current",
58            Before::Clean => "clean",
59            Before::Modified => "modified",
60            Before::Unknown => "unknown",
61            Before::NotTracked => "not_tracked",
62            Before::NotAFile => "not_a_file",
63            Before::UnsafePath => "unsafe_path",
64        }
65    }
66}
67
68/// What `get` did (or, in a dry run, would do) with a requested file.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
70#[serde(rename_all = "snake_case")]
71pub enum Action {
72    Restored,
73    Replaced,
74    Unchanged,
75    Skipped,
76    NotRun,
77    /// Stopped by a cancel request before it started
78    Cancelled,
79    Failed,
80    WouldRestore,
81    WouldReplace,
82}
83
84impl Action {
85    pub fn as_str(&self) -> &'static str {
86        match self {
87            Action::Restored => "restored",
88            Action::Replaced => "replaced",
89            Action::Unchanged => "unchanged",
90            Action::Skipped => "skipped",
91            Action::NotRun => "not_run",
92            Action::Cancelled => "cancelled",
93            Action::Failed => "failed",
94            Action::WouldRestore => "would_restore",
95            Action::WouldReplace => "would_replace",
96        }
97    }
98}
99
100/// Whether the configured storage appears to hold the working file's bytes.
101/// Existence only: it does not prove the stored copy is intact.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
103#[serde(rename_all = "snake_case")]
104pub enum StoredCopy {
105    Present,
106    Absent,
107    Unknown,
108}
109
110impl StoredCopy {
111    pub fn as_str(&self) -> &'static str {
112        match self {
113            StoredCopy::Present => "present",
114            StoredCopy::Absent => "absent",
115            StoredCopy::Unknown => "unknown",
116        }
117    }
118}
119
120/// Why a file did not end up at its target: a get-specific code, or a shared
121/// one. Serialized as the code's string either way.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum GetCode {
124    LocalChanges,
125    UnknownLocal,
126    BatchAborted,
127    NotTracked,
128    NotAFile,
129    UnsafePath,
130    SourceMissing,
131    HashMismatch,
132    DestinationChanged,
133    PermissionDenied,
134    FileInUse,
135    Shared(ErrorCode),
136}
137
138impl From<ErrorCode> for GetCode {
139    fn from(code: ErrorCode) -> Self {
140        GetCode::Shared(code)
141    }
142}
143
144impl GetCode {
145    pub fn as_str(&self) -> &'static str {
146        match self {
147            GetCode::LocalChanges => "local_changes",
148            GetCode::UnknownLocal => "unknown_local",
149            GetCode::BatchAborted => "batch_aborted",
150            GetCode::NotTracked => "not_tracked",
151            GetCode::NotAFile => "not_a_file",
152            GetCode::UnsafePath => "unsafe_path",
153            GetCode::SourceMissing => "source_missing",
154            GetCode::HashMismatch => "hash_mismatch",
155            GetCode::DestinationChanged => "destination_changed",
156            GetCode::PermissionDenied => "permission_denied",
157            GetCode::FileInUse => "file_in_use",
158            GetCode::Shared(code) => code.as_str(),
159        }
160    }
161}
162
163impl Serialize for GetCode {
164    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
165        serializer.serialize_str(self.as_str())
166    }
167}
168
169/// One requested file. Every field is always present (null when not
170/// applicable) so JSON and R consumers get a fixed schema.
171#[derive(Debug, Clone, Serialize)]
172pub struct GetRow {
173    /// Relative to the project root
174    pub path: PathBuf,
175    /// Relative to the current directory
176    pub local_path: PathBuf,
177    pub before: Before,
178    pub action: Action,
179    /// The file is (or after a dry run would be) at its target
180    pub satisfied: bool,
181    /// Size of the target in bytes
182    pub size: Option<u64>,
183    pub target_hash: Option<String>,
184    pub local_hash: Option<String>,
185    /// Only for conflicts
186    pub stored_copy: Option<StoredCopy>,
187    pub code: Option<GetCode>,
188    pub message: Option<String>,
189    /// The next step, in CLI syntax
190    pub hint: Option<String>,
191    /// Time spent on this file (hash + transfer + install); `None` for rows
192    /// never hashed or transferred (cancelled before the plan included).
193    pub duration_ms: Option<u64>,
194}
195
196impl GetRow {
197    /// The working file is a conflict that was left as it is (not replaced).
198    pub fn is_conflict(&self) -> bool {
199        matches!(
200            self.code,
201            Some(GetCode::LocalChanges | GetCode::UnknownLocal)
202        )
203    }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
207#[serde(rename_all = "snake_case")]
208pub enum GetOutcome {
209    /// Every requested file is at its target
210    Complete,
211    /// The policy held the batch back: no file was changed
212    Aborted,
213    /// Not every file is at its target; some are, or were skipped as the
214    /// policy asked
215    Partial,
216    /// No file is at its target or skipped, or the operation could not run
217    Failed,
218    /// Stopped by a cancel request; files that never started are `cancelled`
219    Cancelled,
220}
221
222impl GetOutcome {
223    pub fn as_str(&self) -> &'static str {
224        match self {
225            GetOutcome::Complete => "complete",
226            GetOutcome::Aborted => "aborted",
227            GetOutcome::Partial => "partial",
228            GetOutcome::Failed => "failed",
229            GetOutcome::Cancelled => "cancelled",
230        }
231    }
232}
233
234/// `total` is every row; `succeeded` the rows at their target (restored,
235/// replaced, unchanged, would_restore, would_replace); `failed` the failed action.
236#[derive(Debug, Clone, Default, Serialize)]
237pub struct GetCounts {
238    pub total: usize,
239    pub succeeded: usize,
240    pub restored: usize,
241    pub replaced: usize,
242    pub unchanged: usize,
243    pub skipped: usize,
244    pub not_run: usize,
245    pub cancelled: usize,
246    pub failed: usize,
247    pub would_restore: usize,
248    pub would_replace: usize,
249}
250
251/// The whole result of a `get`: operation information plus one row per file.
252#[derive(Debug, Clone, Serialize)]
253pub struct GetReport {
254    pub outcome: GetOutcome,
255    pub dry_run: bool,
256    pub on_conflict: OnConflict,
257    pub source: SettingSource,
258    pub counts: GetCounts,
259    pub bytes: ByteTotals,
260    /// An operation-level problem, such as nothing matching the request
261    pub problem: Option<Error>,
262    pub files: Vec<GetRow>,
263}
264
265impl GetReport {
266    /// A report for a request that selected nothing or could not run at all.
267    pub fn problem(
268        code: ErrorCode,
269        message: impl Into<String>,
270        dry_run: bool,
271        on_conflict: OnConflict,
272        source: SettingSource,
273    ) -> Self {
274        GetReport {
275            outcome: GetOutcome::Failed,
276            dry_run,
277            on_conflict,
278            source,
279            counts: GetCounts::default(),
280            bytes: ByteTotals::default(),
281            problem: Some(Error::new(code, message)),
282            files: Vec::new(),
283        }
284    }
285
286    /// The summary people read: the outcome in words with the non-zero
287    /// counts, as the CLI prints it under the file lines and R prints it above
288    /// a result. When the policy acted, that is when a file with local changes
289    /// or no record was skipped, held back or replaced, a second line names
290    /// it: `on_conflict = <policy> (<source>)`. A report with a `problem`
291    /// says nothing more than the problem, which callers print instead.
292    pub fn summary(&self) -> String {
293        let c = &self.counts;
294        let what = match self.outcome {
295            GetOutcome::Complete
296                if c.restored + c.replaced + c.would_restore + c.would_replace == 0 =>
297            {
298                format!("nothing to do, {} already current", c.unchanged)
299            }
300            GetOutcome::Complete => counts_phrase(c),
301            GetOutcome::Aborted => {
302                let mut reasons = conflict_reasons(&self.files);
303                if c.failed > 0 {
304                    reasons.push(format!(
305                        "{} cannot be retrieved",
306                        plural(c.failed, "file", "files")
307                    ));
308                }
309                let dry = if self.dry_run { "would be " } else { "" };
310                format!("aborted, nothing {dry}changed: {}", reasons.join("; "))
311            }
312            GetOutcome::Partial | GetOutcome::Failed | GetOutcome::Cancelled => {
313                format!("{}: {}", self.outcome.as_str(), counts_phrase(c))
314            }
315        };
316        let mut line = what;
317        if let Some(first) = line.get(..1) {
318            line.replace_range(..1, &first.to_uppercase());
319        }
320        let policy_acted = self
321            .files
322            .iter()
323            .any(|r| matches!(r.before, Before::Modified | Before::Unknown));
324        if policy_acted {
325            line.push_str(&format!(
326                "\non_conflict = {} ({})",
327                self.on_conflict, self.source
328            ));
329        }
330        line
331    }
332
333    /// Whether get did as asked. A skip the policy asked for counts as done.
334    pub fn verdict(&self) -> GetVerdict {
335        if self.outcome == GetOutcome::Cancelled {
336            GetVerdict::Cancelled
337        } else if self.problem.is_some() || self.counts.failed > 0 {
338            GetVerdict::Failed
339        } else if self.outcome == GetOutcome::Aborted {
340            GetVerdict::HeldBack
341        } else {
342            GetVerdict::Done
343        }
344    }
345}
346
347fn plural(n: usize, one: &str, many: &str) -> String {
348    format!("{n} {}", if n == 1 { one } else { many })
349}
350
351/// The non-zero counts, in a fixed order: "restored 2, 3 already current".
352fn counts_phrase(c: &GetCounts) -> String {
353    let parts = [
354        (c.restored, "restored {}"),
355        (c.replaced, "replaced {}"),
356        (c.would_restore, "would restore {}"),
357        (c.would_replace, "would replace {}"),
358        (c.unchanged, "{} already current"),
359        (c.skipped, "skipped {}"),
360        (c.not_run, "{} held back"),
361        (c.cancelled, "cancelled {}"),
362        (c.failed, "{} failed"),
363    ];
364    let out: Vec<String> = parts
365        .iter()
366        .filter(|(n, _)| *n > 0)
367        .map(|(n, f)| f.replace("{}", &n.to_string()))
368        .collect();
369    if out.is_empty() {
370        "nothing to do".to_string()
371    } else {
372        out.join(", ")
373    }
374}
375
376/// Why conflicts stopped things, separating edits from files with no record.
377fn conflict_reasons(files: &[GetRow]) -> Vec<String> {
378    let count = |b: Before| {
379        files
380            .iter()
381            .filter(|r| r.is_conflict() && r.before == b)
382            .count()
383    };
384    let (modified, unknown) = (count(Before::Modified), count(Before::Unknown));
385    let mut reasons = Vec::new();
386    if modified > 0 {
387        reasons.push(format!(
388            "{} local changes",
389            plural(modified, "file has", "files have")
390        ));
391    }
392    if unknown > 0 {
393        reasons.push(format!(
394            "{} from the tracked version and dvs has no record of writing {}",
395            plural(unknown, "file differs", "files differ"),
396            if unknown == 1 { "it" } else { "them" }
397        ));
398    }
399    reasons
400}
401
402/// How a get ended, for exit codes and R's `on_error`.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum GetVerdict {
405    /// Did as asked, including leaving files the policy asked to skip
406    Done,
407    /// A file failed, or the operation itself had a problem
408    Failed,
409    /// `abort` held files back because of conflicts; nothing was written
410    HeldBack,
411    /// A cancel came before the run finished
412    Cancelled,
413}
414
415/// An add or status outcome in the same terms, so one mapping gives every
416/// command its exit status.
417impl From<RunOutcome> for GetVerdict {
418    fn from(outcome: RunOutcome) -> Self {
419        match outcome {
420            RunOutcome::Complete => GetVerdict::Done,
421            RunOutcome::Partial | RunOutcome::Failed => GetVerdict::Failed,
422            RunOutcome::Cancelled => GetVerdict::Cancelled,
423        }
424    }
425}
426
427#[derive(Debug, Clone, Copy)]
428pub struct GetOptions {
429    pub dry_run: bool,
430    pub on_conflict: OnConflict,
431    pub source: SettingSource,
432}
433
434impl Default for GetOptions {
435    fn default() -> Self {
436        GetOptions {
437            dry_run: false,
438            on_conflict: OnConflict::Abort,
439            source: SettingSource::Default,
440        }
441    }
442}
443
444/// A row being worked on, with what the plan learned about the working file.
445struct Planned {
446    row: GetRow,
447    metadata: Option<FileMetadata>,
448    /// The working file's stat when it was classified, to detect it changing
449    /// while its replacement downloads. None when it was absent.
450    stat: Option<FileStat>,
451    /// Time spent classifying it (mostly hashing), part of its `duration_ms`.
452    hash_time: Duration,
453    /// Hashing was attempted, whether or not it succeeded.
454    hashed: bool,
455    /// Already reported through `file_finished`: every row is, exactly once.
456    emitted: bool,
457}
458
459impl Planned {
460    fn new(row: GetRow) -> Self {
461        Planned {
462            row,
463            metadata: None,
464            stat: None,
465            hash_time: Duration::ZERO,
466            hashed: false,
467            emitted: false,
468        }
469    }
470
471    /// Report the row as final, unless it already was.
472    fn emit(&mut self, ctx: &OperationContext) {
473        if !self.emitted {
474            self.emitted = true;
475            self.fill_hash_duration();
476            self.row.hint = hint(&self.row);
477            ctx.observer().file_finished(FileRow::Get(&self.row));
478        }
479    }
480
481    /// A row that was hashed carries at least its hash time, whatever
482    /// happened to it after (cancelled included).
483    fn fill_hash_duration(&mut self) {
484        if self.row.duration_ms.is_none() && self.hashed {
485            self.row.duration_ms = Some(millis(self.hash_time));
486        }
487    }
488
489    fn needs_transfer(&self, on_conflict: OnConflict) -> bool {
490        self.row.action == Action::NotRun
491            && (matches!(self.row.before, Before::Absent | Before::Clean)
492                || (self.row.is_conflict() && on_conflict == OnConflict::Overwrite))
493    }
494
495    /// Detected before anything is written: under abort/overwrite, any of these
496    /// holds the whole batch back. That includes every file the plan could not
497    /// classify (unreadable or malformed metadata, e.g. a merge conflict in a
498    /// `.dvs` file).
499    fn is_known_problem(&self, on_conflict: OnConflict) -> bool {
500        self.row.action == Action::Failed
501            || (self.row.is_conflict() && on_conflict != OnConflict::Overwrite)
502    }
503
504    fn set(&mut self, action: Action, code: Option<GetCode>, message: Option<String>) {
505        set_row(&mut self.row, action, code, message);
506    }
507}
508
509fn set_row(row: &mut GetRow, action: Action, code: Option<GetCode>, message: Option<String>) {
510    row.action = action;
511    row.satisfied = matches!(
512        action,
513        Action::Restored
514            | Action::Replaced
515            | Action::Unchanged
516            | Action::WouldRestore
517            | Action::WouldReplace
518    );
519    row.code = code;
520    row.message = message;
521}
522
523/// A file a cancel stopped before it started.
524fn cancel_row(row: &mut GetRow) {
525    let message = "cancelled before it started".to_string();
526    set_row(
527        row,
528        Action::Cancelled,
529        Some(GetCode::Shared(ErrorCode::Cancelled)),
530        Some(message),
531    );
532}
533
534/// An absolute path as a path from the project root, if it is inside it
535/// (also when one side has symlinks resolved, e.g. macOS /var -> /private/var).
536fn project_relative(root: &Path, absolute: &Path) -> Option<PathBuf> {
537    let absolute = normalize(absolute);
538    if let Ok(rel) = absolute.strip_prefix(root) {
539        return Some(rel.to_path_buf());
540    }
541    let root = root.canonicalize().ok()?;
542    let parent = absolute.parent()?.canonicalize().ok()?;
543    let rel = parent.join(absolute.file_name()?);
544    rel.strip_prefix(&root).ok().map(Path::to_path_buf)
545}
546
547/// `target` lexically relative to `base`; both absolute.
548fn relative_to(base: &Path, target: &Path) -> PathBuf {
549    let base: Vec<_> = base.components().collect();
550    let target: Vec<_> = target.components().collect();
551    let common = base
552        .iter()
553        .zip(target.iter())
554        .take_while(|(a, b)| a == b)
555        .count();
556    let mut out = PathBuf::new();
557    for _ in common..base.len() {
558        out.push("..");
559    }
560    for c in &target[common..] {
561        out.push(c.as_os_str());
562    }
563    if out.as_os_str().is_empty() {
564        out.push(".");
565    }
566    out
567}
568
569/// Resolve `.` and `..` without touching the filesystem.
570fn normalize(path: &Path) -> PathBuf {
571    let mut out = PathBuf::new();
572    for c in path.components() {
573        match c {
574            Component::CurDir => {}
575            Component::ParentDir => {
576                if !out.pop() {
577                    out.push("..");
578                }
579            }
580            other => out.push(other.as_os_str()),
581        }
582    }
583    out
584}
585
586/// True when the nearest existing ancestor of `target` resolves inside `root`,
587/// so writing `target` cannot escape the project through a symlinked directory.
588fn destination_is_contained(root: &Path, target: &Path) -> bool {
589    let mut dir = target.parent();
590    while let Some(d) = dir {
591        match std::fs::symlink_metadata(d) {
592            Ok(_) => {
593                return std::fs::canonicalize(d)
594                    .map(|real| real.starts_with(root))
595                    .unwrap_or(false);
596            }
597            Err(_) => dir = d.parent(),
598        }
599    }
600    false
601}
602
603fn blank_row(paths: &DvsPaths, relative_path: &Path) -> GetRow {
604    GetRow {
605        path: relative_path.to_path_buf(),
606        local_path: relative_to(paths.cwd(), &paths.file_path(relative_path)),
607        before: Before::Absent,
608        action: Action::NotRun,
609        satisfied: false,
610        size: None,
611        target_hash: None,
612        local_hash: None,
613        stored_copy: None,
614        code: None,
615        message: None,
616        hint: None,
617        duration_ms: None,
618    }
619}
620
621/// Classify one tracked file. Reads only local state. `hashing` begins
622/// just before the working file is hashed.
623fn plan_file(
624    paths: &DvsPaths,
625    relative_path: &Path,
626    cache: Option<&Mutex<HashCache>>,
627    hashing: &Hashing<'_>,
628) -> Planned {
629    let mut planned = Planned::new(blank_row(paths, relative_path));
630    let fail = |mut planned: Planned, before: Before, code: GetCode, message: String| {
631        planned.row.before = before;
632        planned.set(Action::Failed, Some(code), Some(message));
633        planned
634    };
635
636    let metadata_path = paths.metadata_path(relative_path);
637    let metadata: FileMetadata = match fs::File::open(&metadata_path)
638        .map_err(anyhow::Error::from)
639        .and_then(|f| serde_json::from_reader(f).map_err(anyhow::Error::from))
640    {
641        Ok(m) => m,
642        Err(e) => {
643            return fail(
644                planned,
645                Before::Unknown,
646                GetCode::Shared(ErrorCode::BadMetadata),
647                format!("cannot read {}: {e}", metadata_path.display()),
648            );
649        }
650    };
651    planned.row.size = Some(metadata.size);
652    planned.row.target_hash = Some(metadata.hashes.blake3.clone());
653
654    let target = paths.file_path(relative_path);
655    if !destination_is_contained(paths.repo_root(), &target) {
656        return fail(
657            planned,
658            Before::UnsafePath,
659            GetCode::UnsafePath,
660            "a parent directory leads outside the project (symlink?); dvs will not write there"
661                .to_string(),
662        );
663    }
664
665    match std::fs::symlink_metadata(&target) {
666        Err(e) if e.kind() == io::ErrorKind::NotFound => {
667            planned.row.before = Before::Absent;
668            planned.metadata = Some(metadata);
669            return planned;
670        }
671        Err(e) => {
672            return fail(planned, Before::Unknown, io_code(&e), e.to_string());
673        }
674        Ok(meta) if meta.file_type().is_symlink() => {
675            return fail(
676                planned,
677                Before::NotAFile,
678                GetCode::NotAFile,
679                "is a symbolic link; dvs will not replace it or write through it".to_string(),
680            );
681        }
682        Ok(meta) if !meta.is_file() => {
683            let what = if meta.is_dir() {
684                "is a directory"
685            } else {
686                "is not a regular file"
687            };
688            return fail(
689                planned,
690                Before::NotAFile,
691                GetCode::NotAFile,
692                format!("{what}; dvs will not replace it"),
693            );
694        }
695        Ok(_) => {}
696    }
697
698    let rel_str = relative_path.to_string_lossy();
699    let stat = FileStat::from_path(&target).ok();
700    hashing.begin();
701    planned.hashed = true;
702    let (hashes, size) = match cache::hashes_for_file(&target, &rel_str, cache) {
703        Ok(h) => h,
704        Err(e) => {
705            let code = e
706                .downcast_ref::<io::Error>()
707                .map(io_code)
708                .unwrap_or(GetCode::Shared(ErrorCode::IoError));
709            return fail(
710                planned,
711                Before::Unknown,
712                code,
713                format!("cannot read the working file: {e}"),
714            );
715        }
716    };
717    planned.row.local_hash = Some(hashes.blake3.clone());
718    planned.stat = stat;
719
720    planned.row.before = if hashes == metadata.hashes && size == metadata.size {
721        Before::Current
722    } else {
723        match baseline_for(cache, &rel_str) {
724            Some((blake3, base_size)) if blake3 == hashes.blake3 && base_size == size => {
725                Before::Clean
726            }
727            Some(_) => Before::Modified,
728            None => Before::Unknown,
729        }
730    };
731    let conflict = match planned.row.before {
732        Before::Modified => Some((
733            GetCode::LocalChanges,
734            "local changes since dvs last wrote this file",
735        )),
736        Before::Unknown => Some((
737            GetCode::UnknownLocal,
738            "differs from the tracked version, and dvs has no record of writing it",
739        )),
740        _ => None,
741    };
742    if let Some((code, message)) = conflict {
743        planned.set(Action::NotRun, Some(code), Some(message.to_string()));
744    }
745    planned.metadata = Some(metadata);
746    planned
747}
748
749fn io_code(e: &io::Error) -> GetCode {
750    // ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION: open in another program
751    if cfg!(windows) && matches!(e.raw_os_error(), Some(32) | Some(33)) {
752        return GetCode::FileInUse;
753    }
754    match e.kind() {
755        io::ErrorKind::PermissionDenied => GetCode::PermissionDenied,
756        _ => GetCode::Shared(ErrorCode::IoError),
757    }
758}
759
760/// The code for a failure: server unreachable or refusing, else by I/O kind.
761fn code_for(e: &anyhow::Error) -> GetCode {
762    if let Some(code) = backend_error_code(e) {
763        return code.into();
764    }
765    e.chain()
766        .find_map(|cause| cause.downcast_ref::<io::Error>())
767        .map(io_code)
768        .unwrap_or(GetCode::Shared(ErrorCode::IoError))
769}
770
771/// Whether the destination is still exactly as the plan saw it.
772fn destination_unchanged(target: &Path, planned: &Option<FileStat>) -> bool {
773    match planned {
774        None => std::fs::symlink_metadata(target).is_err(),
775        Some(stat) => FileStat::from_path(target).ok().as_ref() == Some(stat),
776    }
777}
778
779/// A per-file failure: its code and message.
780type FileError = (GetCode, String);
781
782/// Download, verify and install one file. The existing working file is only
783/// replaced after the new bytes are verified and the destination is unchanged.
784fn install(
785    backend: &Backend,
786    paths: &DvsPaths,
787    relative_path: &Path,
788    metadata: &FileMetadata,
789    planned_stat: &Option<FileStat>,
790    operation_id: Uuid,
791    on_bytes: Option<&(dyn Fn(u64) + Send + Sync)>,
792) -> Result<(), FileError> {
793    let target = paths.file_path(relative_path);
794    if let Some(parent) = target.parent() {
795        fs::create_dir_all(parent)
796            .map_err(|e| (io_code(&e), format!("cannot create its directory: {e}")))?;
797    }
798    let tmp_path = target.with_extension(format!("dvs-tmp.{}", Uuid::new_v4()));
799    let path = ProjectPath::from_path(relative_path)
800        .map_err(|e| (GetCode::Shared(ErrorCode::IoError), e.to_string()))?;
801
802    let result = (|| {
803        let retrieved = backend
804            .retrieve(RetrieveRequest {
805                hashes: &metadata.hashes,
806                target: &tmp_path,
807                compression: metadata.compression,
808                path,
809                operation_id,
810                on_bytes,
811            })
812            .map_err(|e| (code_for(&e), format!("retrieval failed: {e:#}")))?;
813        if !retrieved {
814            return Err((
815                GetCode::SourceMissing,
816                "this version is not in storage".to_string(),
817            ));
818        }
819        let actual = FileMetadata::from_file(&tmp_path, Compression::None, None)
820            .map_err(|e| (code_for(&e), e.to_string()))?;
821        if actual.hashes != metadata.hashes {
822            return Err((
823                GetCode::HashMismatch,
824                "the stored content does not match the tracked hash".to_string(),
825            ));
826        }
827        if !destination_unchanged(&target, planned_stat) {
828            return Err((
829                GetCode::DestinationChanged,
830                "the file changed while dvs was downloading it; your version was kept".to_string(),
831            ));
832        }
833        fs::rename(&tmp_path, &target)
834            .map_err(|e| (io_code(&e), format!("cannot replace the file: {e}")))?;
835        Ok(())
836    })();
837    if result.is_err() {
838        let _ = fs::remove_file(&tmp_path);
839    }
840    result
841}
842
843/// Gets the selected files from storage, following `options.on_conflict`.
844///
845/// Never errors: every problem is reported in the returned rows or as the
846/// report's `problem`.
847///
848/// Reports to `ctx`'s observer as it goes: hashing (only when the plan hashes
849/// some working file), `planned` once the plan and storage checks are done,
850/// then every row through `file_finished` exactly once, as soon as it is final
851/// (rows decided before the transfers first, then each transfer as it
852/// completes). A cancel stops files from *starting*: a file already
853/// downloading finishes (verified temp file + rename), the rest are `cancelled`.
854pub fn get_files(
855    selection: GetSelection,
856    paths: &DvsPaths,
857    backend: &Backend,
858    options: GetOptions,
859    ctx: &OperationContext,
860) -> GetReport {
861    let GetOptions {
862        dry_run,
863        on_conflict,
864        source,
865    } = options;
866    // A run that stops before planning is still cancelled if a cancel came first
867    let problem = |code: ErrorCode, message: String| {
868        let mut report = GetReport::problem(code, message, dry_run, on_conflict, source);
869        if ctx.is_cancelled() {
870            report.outcome = GetOutcome::Cancelled;
871        }
872        report
873    };
874    if selection.tracked.is_empty() && selection.unmatched.is_empty() {
875        return problem(
876            ErrorCode::NoFilesSelected,
877            "no tracked files matched".into(),
878        );
879    }
880
881    let pool = match get_threadpool(selection.tracked.len().max(1)) {
882        Ok(p) => p,
883        Err(e) => return problem(ErrorCode::OperationFailed, e.to_string()),
884    };
885    let cache = try_open_cache(paths);
886    let cache = cache.as_ref();
887    let observer = ctx.observer();
888    let ending = |planned, aborted| {
889        finish(
890            planned,
891            Ending {
892                dry_run,
893                on_conflict,
894                source,
895                aborted,
896            },
897            ctx,
898        )
899    };
900
901    // 1. Plan: classify every file, locally, writing nothing. Hashing is
902    // reported from the first file actually hashed, so a plan that hashes
903    // nothing (every file absent, or a cancel first) reports none.
904    let hashing = Hashing::new(observer);
905    let mut planned: Vec<Planned> = pool.install(|| {
906        selection
907            .tracked
908            .into_par_iter()
909            .map(|p| {
910                // Hashing is starting work too: a cancel stops it. The
911                // row is `unknown` only because nothing looked at the file.
912                if ctx.is_cancelled() {
913                    let mut planned = Planned::new(blank_row(paths, &p));
914                    planned.row.before = Before::Unknown;
915                    cancel_row(&mut planned.row);
916                    return planned;
917                }
918                let started = Instant::now();
919                let mut planned = plan_file(paths, &p, cache, &hashing);
920                planned.hash_time = started.elapsed();
921                planned
922            })
923            .collect()
924    });
925    hashing.finish();
926    let cwd_rel = paths
927        .cwd_relative_to_root()
928        .map(Path::to_path_buf)
929        .unwrap_or_default();
930    for user_path in selection.unmatched {
931        // Report it like a tracked file: `path` from the project root, and
932        // `local_path` from the cwd, however the user wrote it.
933        let inside = if user_path.is_absolute() {
934            project_relative(paths.repo_root(), &user_path)
935        } else {
936            Some(normalize(&cwd_rel.join(&user_path)))
937        };
938        let mut p = Planned::new(blank_row(paths, inside.as_deref().unwrap_or(&user_path)));
939        let is_dir = paths.file_path(&p.row.path).is_dir();
940        if inside.is_none() {
941            p.row.local_path = user_path;
942        }
943        p.row.before = Before::NotTracked;
944        p.set(
945            Action::Failed,
946            Some(GetCode::NotTracked),
947            Some(if is_dir {
948                "no tracked files selected in this directory".to_string()
949            } else {
950                "not tracked by dvs".to_string()
951            }),
952        );
953        planned.push(p);
954    }
955    planned.sort_by(|a, b| a.row.path.cmp(&b.row.path));
956
957    for p in planned
958        .iter_mut()
959        .filter(|p| p.row.before == Before::Current)
960    {
961        p.set(Action::Unchanged, None, None);
962        // Hashing it was the work: nothing to transfer.
963        p.row.duration_ms = Some(millis(p.hash_time));
964        if !dry_run {
965            if let Some(m) = &p.metadata {
966                record_baseline(
967                    cache,
968                    &p.row.path.to_string_lossy(),
969                    &m.hashes.blake3,
970                    m.size,
971                );
972            }
973        }
974    }
975
976    // One access check for everything that needs the remote: transfers, and
977    // the stored-copy hints on conflicts. Files already current need neither.
978    // A cancel by now stops the storage checks from starting too:
979    // no access check and no stored-copy lookups.
980    let cancelled_before_storage = ctx.is_cancelled();
981    let needs_remote = !cancelled_before_storage
982        && planned
983            .iter()
984            .any(|p| p.needs_transfer(on_conflict) || p.row.is_conflict());
985    let access = if needs_remote {
986        backend.check_access().map_err(access_failure)
987    } else {
988        Ok(())
989    };
990    for p in planned.iter_mut().filter(|p| p.row.is_conflict()) {
991        p.row.stored_copy = Some(match (&access, &p.row.local_hash) {
992            (Ok(()), Some(blake3)) if needs_remote => {
993                let hashes = crate::Hashes {
994                    blake3: blake3.clone(),
995                    md5: None,
996                };
997                match backend.exists(&hashes) {
998                    Ok(true) => StoredCopy::Present,
999                    Ok(false) => StoredCopy::Absent,
1000                    Err(_) => StoredCopy::Unknown,
1001                }
1002            }
1003            _ => StoredCopy::Unknown,
1004        });
1005    }
1006
1007    // 2. Decide: only `skip` continues past a known problem.
1008    let blocked =
1009        on_conflict != OnConflict::Skip && planned.iter().any(|p| p.is_known_problem(on_conflict));
1010    if blocked {
1011        for p in planned.iter_mut() {
1012            // Conflicts kept by the policy already say why they are not run.
1013            let kept = p.row.is_conflict() && on_conflict != OnConflict::Overwrite;
1014            if p.row.before == Before::Current
1015                || p.row.action == Action::Failed
1016                || kept
1017                || p.row.action == Action::Cancelled
1018            {
1019                continue;
1020            }
1021            p.set(
1022                Action::NotRun,
1023                Some(GetCode::BatchAborted),
1024                Some("held back: another requested file has a problem".to_string()),
1025            );
1026        }
1027        let plan = operation_plan(&planned, &[]);
1028        observer.planned(&plan);
1029        return ending(planned, true);
1030    }
1031    for p in planned
1032        .iter_mut()
1033        .filter(|p| p.row.is_conflict() && on_conflict == OnConflict::Skip)
1034    {
1035        p.row.action = Action::Skipped;
1036    }
1037
1038    // 3. Execute the transfers. Each file succeeds or fails on its own.
1039    // Cancelled before the storage checks: no transfer starts, so the plan
1040    // reports none and each would-be transfer ends cancelled, keeping its
1041    // hash time if it was hashed.
1042    if cancelled_before_storage {
1043        for p in planned.iter_mut().filter(|p| p.needs_transfer(on_conflict)) {
1044            cancel_row(&mut p.row);
1045        }
1046    }
1047    let transfer_idx: Vec<usize> = planned
1048        .iter()
1049        .enumerate()
1050        .filter(|(_, p)| p.needs_transfer(on_conflict))
1051        .map(|(i, _)| i)
1052        .collect();
1053    let plan = operation_plan(&planned, &transfer_idx);
1054    if let Err((code, message)) = &access {
1055        for &i in &transfer_idx {
1056            planned[i].set(Action::Failed, Some(*code), Some(message.clone()));
1057        }
1058        observer.planned(&plan);
1059        return ending(planned, false);
1060    }
1061    observer.planned(&plan);
1062    // Everything that will not be transferred is final now.
1063    for p in planned.iter_mut() {
1064        if !p.needs_transfer(on_conflict) {
1065            p.emit(ctx);
1066        }
1067    }
1068
1069    let operation_id = Uuid::new_v4();
1070    let finished: Vec<(usize, GetRow)> = pool.install(|| {
1071        transfer_idx
1072            .par_iter()
1073            .map(|&i| {
1074                let p = &planned[i];
1075                let mut row = p.row.clone();
1076                // Stop starting files; the ones already started run to the end.
1077                if ctx.is_cancelled() {
1078                    cancel_row(&mut row);
1079                    // Hashed in the plan: that was its work
1080                    if p.hashed {
1081                        row.duration_ms = Some(millis(p.hash_time));
1082                    }
1083                    observer.file_finished(FileRow::Get(&row));
1084                    return (i, row);
1085                }
1086                let started = Instant::now();
1087                let result = transfer(p, backend, paths, dry_run, operation_id, ctx);
1088                settle(&mut row, p, result, dry_run, paths, cache);
1089                row.duration_ms = Some(millis(p.hash_time + started.elapsed()));
1090                row.hint = hint(&row);
1091                // Final the moment its own transfer is done, not when the batch is.
1092                observer.file_finished(FileRow::Get(&row));
1093                (i, row)
1094            })
1095            .collect()
1096    });
1097    for (i, row) in finished {
1098        planned[i].row = row;
1099        planned[i].emitted = true;
1100    }
1101
1102    ending(planned, false)
1103}
1104
1105/// Fetch one planned file (a dry run only asks storage whether it is there).
1106fn transfer(
1107    p: &Planned,
1108    backend: &Backend,
1109    paths: &DvsPaths,
1110    dry_run: bool,
1111    operation_id: Uuid,
1112    ctx: &OperationContext,
1113) -> Result<(), FileError> {
1114    let metadata = p
1115        .metadata
1116        .as_ref()
1117        .expect("planned transfers have metadata");
1118    if dry_run {
1119        return match backend.exists(&metadata.hashes) {
1120            Ok(true) => Ok(()),
1121            Ok(false) => Err((
1122                GetCode::SourceMissing,
1123                "this version is not in storage".to_string(),
1124            )),
1125            Err(e) => Err((code_for(&e), format!("{e:#}"))),
1126        };
1127    }
1128    let file_progress = ctx.observer().transfer_started(&p.row.path, metadata.size);
1129    let on_bytes = file_progress.as_ref().map(|fp| &*fp.on_bytes);
1130    let result = install(
1131        backend,
1132        paths,
1133        &p.row.path,
1134        metadata,
1135        &p.stat,
1136        operation_id,
1137        on_bytes,
1138    );
1139    if let Some(fp) = &file_progress {
1140        (fp.on_done)(result.is_ok());
1141    }
1142    result
1143}
1144
1145/// Make `row` (a copy of `p.row`) final for its transfer's `result`, and
1146/// record what a real install wrote.
1147fn settle(
1148    row: &mut GetRow,
1149    p: &Planned,
1150    result: Result<(), FileError>,
1151    dry_run: bool,
1152    paths: &DvsPaths,
1153    cache: Option<&Mutex<HashCache>>,
1154) {
1155    let was_absent = p.row.before == Before::Absent;
1156    let was_conflict = p.row.is_conflict();
1157    match result {
1158        Ok(()) => {
1159            let action = match (dry_run, was_absent) {
1160                (true, true) => Action::WouldRestore,
1161                (true, false) => Action::WouldReplace,
1162                (false, true) => Action::Restored,
1163                (false, false) => Action::Replaced,
1164            };
1165            let message = was_conflict.then(|| {
1166                match (dry_run, p.row.before == Before::Modified) {
1167                    (true, true) => "local changes would be discarded",
1168                    (false, true) => "local changes were discarded",
1169                    (true, false) => "the local version would be replaced",
1170                    (false, false) => "the local version was replaced",
1171                }
1172                .to_string()
1173            });
1174            set_row(row, action, None, message);
1175            if !dry_run {
1176                let metadata = p
1177                    .metadata
1178                    .as_ref()
1179                    .expect("planned transfers have metadata");
1180                let rel_str = p.row.path.to_string_lossy().into_owned();
1181                record_baseline(cache, &rel_str, &metadata.hashes.blake3, metadata.size);
1182                if let Some(mtx) = cache {
1183                    if let Ok(stat) = FileStat::from_path(&paths.file_path(&p.row.path)) {
1184                        if let Err(e) =
1185                            mtx.lock()
1186                                .unwrap()
1187                                .insert(&rel_str, &stat, &metadata.hashes)
1188                        {
1189                            log::warn!("Cache store failed after get for {rel_str}: {e}");
1190                        }
1191                    }
1192                }
1193                log::info!("Retrieved {} ({})", p.row.path.display(), action.as_str());
1194            }
1195        }
1196        Err((code, message)) => {
1197            log::warn!("Failed to get {}: {message}", p.row.path.display());
1198            set_row(row, Action::Failed, Some(code), Some(message));
1199        }
1200    }
1201}
1202
1203/// The code every file needing the remote gets when the access check fails.
1204/// An unclassified failure is still the remote being unusable, not an I/O error.
1205fn access_failure(e: anyhow::Error) -> FileError {
1206    let code = backend_error_code(&e)
1207        .map(GetCode::from)
1208        .unwrap_or(GetCode::Shared(ErrorCode::ServerUnavailable));
1209    (code, format!("{e:#}"))
1210}
1211
1212/// Quote a path for a shell command line when it needs it.
1213pub fn shell_path(path: &Path) -> String {
1214    let s = path.to_string_lossy();
1215    if s.chars()
1216        .all(|c| c.is_ascii_alphanumeric() || "/._-+".contains(c))
1217    {
1218        s.into_owned()
1219    } else {
1220        format!("'{}'", s.replace('\'', r"'\''"))
1221    }
1222}
1223
1224/// The next command to run for a row.
1225fn hint(row: &GetRow) -> Option<String> {
1226    let p = shell_path(&row.local_path);
1227    let choices = format!(
1228        "keep your version: dvs add {p} (makes it the tracked version); \
1229         take the tracked version: dvs get --force {p} (replaces yours)"
1230    );
1231    match row.code? {
1232        GetCode::LocalChanges => Some(choices),
1233        GetCode::UnknownLocal => Some(format!(
1234            "dvs has no record of writing this file (a clone from before dvs recorded this, \
1235             or a cleared cache); {choices}"
1236        )),
1237        GetCode::BatchAborted => {
1238            Some("held back by another file; to get the rest: --on-conflict skip".into())
1239        }
1240        GetCode::NotTracked if row.local_path.is_dir() => Some(
1241            "nothing tracked is selected here; add -r to include subdirectories, or check the path"
1242                .into(),
1243        ),
1244        GetCode::NotTracked => Some("check the path; list tracked files with: dvs status".into()),
1245        GetCode::SourceMissing => Some(
1246            "the stored content of this version is missing (probably an interrupted upload); \
1247             tell whoever added it"
1248                .into(),
1249        ),
1250        GetCode::Shared(ErrorCode::AccessDenied) => {
1251            Some("check you are logged in with access: dvs info, dvs login".into())
1252        }
1253        GetCode::Shared(ErrorCode::ServerUnavailable) => {
1254            Some("check your network or VPN and the server, then run dvs get again".into())
1255        }
1256        GetCode::DestinationChanged => Some(format!("run again once nothing is writing {p}")),
1257        GetCode::FileInUse => {
1258            Some("close the program using the file, then run dvs get again".into())
1259        }
1260        GetCode::Shared(ErrorCode::BadMetadata) => Some(format!(
1261            "fix the .dvs file for {p} (for example a git merge conflict), then run dvs get again"
1262        )),
1263        _ => None,
1264    }
1265}
1266
1267/// The plan as reported to the observer: every row, and the ones to download.
1268/// Rows of unknown size count in no byte total.
1269fn operation_plan(planned: &[Planned], transfer_idx: &[usize]) -> OperationPlan {
1270    let size = |p: &Planned| p.row.size.unwrap_or(0);
1271    OperationPlan {
1272        files_total: planned.len() as u64,
1273        bytes_total: planned.iter().map(size).sum(),
1274        files_to_transfer: transfer_idx.len() as u64,
1275        bytes_to_transfer: transfer_idx.iter().map(|&i| size(&planned[i])).sum(),
1276    }
1277}
1278
1279/// How a `get` ended, for [`finish`].
1280struct Ending {
1281    dry_run: bool,
1282    on_conflict: OnConflict,
1283    source: SettingSource,
1284    aborted: bool,
1285}
1286
1287fn finish(planned: Vec<Planned>, ending: Ending, ctx: &OperationContext) -> GetReport {
1288    let Ending {
1289        dry_run,
1290        on_conflict,
1291        source,
1292        aborted,
1293    } = ending;
1294    let files: Vec<GetRow> = planned
1295        .into_iter()
1296        .map(|mut p| {
1297            p.emit(ctx);
1298            p.row
1299        })
1300        .collect();
1301    let mut counts = GetCounts {
1302        total: files.len(),
1303        succeeded: files.iter().filter(|r| r.satisfied).count(),
1304        ..GetCounts::default()
1305    };
1306    for r in &files {
1307        let slot = match r.action {
1308            Action::Restored => &mut counts.restored,
1309            Action::Replaced => &mut counts.replaced,
1310            Action::Unchanged => &mut counts.unchanged,
1311            Action::Skipped => &mut counts.skipped,
1312            Action::NotRun => &mut counts.not_run,
1313            Action::Cancelled => &mut counts.cancelled,
1314            Action::Failed => &mut counts.failed,
1315            Action::WouldRestore => &mut counts.would_restore,
1316            Action::WouldReplace => &mut counts.would_replace,
1317        };
1318        *slot += 1;
1319    }
1320    // `to_transfer` counts every row not at its target, whatever then happened
1321    // to it (cancelled included)
1322    let mut bytes = ByteTotals::default();
1323    for r in &files {
1324        if let Some(size) = r.size {
1325            let installed = matches!(r.action, Action::Restored | Action::Replaced);
1326            bytes.count(size, r.before == Before::Current, installed);
1327        }
1328    }
1329    // A cancel request decides the outcome, even when no row ended up
1330    // cancelled (every file already in flight)
1331    let outcome = if ctx.is_cancelled() {
1332        GetOutcome::Cancelled
1333    } else if aborted {
1334        GetOutcome::Aborted
1335    } else if counts.succeeded == counts.total {
1336        GetOutcome::Complete
1337    } else if counts.succeeded + counts.skipped == 0 {
1338        GetOutcome::Failed
1339    } else {
1340        GetOutcome::Partial
1341    };
1342    GetReport {
1343        outcome,
1344        dry_run,
1345        on_conflict,
1346        source,
1347        counts,
1348        bytes,
1349        problem: None,
1350        files,
1351    }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357    use crate::add_files;
1358    use crate::config::Config;
1359    use crate::testutil::{create_file, create_temp_git_repo, init_dvs_repo};
1360    use std::collections::HashSet;
1361    use tempfile::TempDir;
1362
1363    struct TestRepo {
1364        _tmp: TempDir,
1365        root: PathBuf,
1366        config: Config,
1367        paths: DvsPaths,
1368    }
1369
1370    impl TestRepo {
1371        fn new() -> Self {
1372            let (_tmp, root) = create_temp_git_repo();
1373            let (config, _dvs_dir) = init_dvs_repo(&root);
1374            let paths =
1375                DvsPaths::new(root.clone(), root.clone(), config.metadata_folder_name()).unwrap();
1376            Self {
1377                _tmp,
1378                root,
1379                config,
1380                paths,
1381            }
1382        }
1383
1384        fn backend(&self) -> &Backend {
1385            self.config.backend()
1386        }
1387
1388        /// Write `content` at `name` and `dvs add` it.
1389        fn add(&self, name: &str, content: &[u8]) {
1390            create_file(&self.root, name, content);
1391            let results = add_files(
1392                vec![name.into()],
1393                &self.paths,
1394                self.backend(),
1395                None,
1396                Compression::Zstd,
1397                false,
1398                &OperationContext::new(),
1399            )
1400            .unwrap()
1401            .files;
1402            assert!(matches!(
1403                results[0].detail,
1404                crate::AddDetail::Success { .. }
1405            ));
1406        }
1407
1408        fn metadata_bytes(&self, name: &str) -> Vec<u8> {
1409            fs::read(self.paths.metadata_path(Path::new(name))).unwrap()
1410        }
1411
1412        /// Stand-in for `git checkout`: put back an earlier `.dvs` file.
1413        fn set_metadata_bytes(&self, name: &str, bytes: &[u8]) {
1414            fs::write(self.paths.metadata_path(Path::new(name)), bytes).unwrap();
1415        }
1416
1417        /// The state of a clone where `name` is tracked at `v1` but the working
1418        /// file holds `v2`, which dvs itself put there (a branch switch).
1419        fn switched_branch(&self, name: &str, v1: &[u8], v2: &[u8]) {
1420            self.add(name, v1);
1421            let old = self.metadata_bytes(name);
1422            self.add(name, v2);
1423            self.set_metadata_bytes(name, &old);
1424        }
1425
1426        fn read(&self, name: &str) -> Vec<u8> {
1427            fs::read(self.root.join(name)).unwrap()
1428        }
1429
1430        fn get(&self, names: &[&str], on_conflict: OnConflict) -> GetReport {
1431            self.get_with(names, &[], on_conflict, false)
1432        }
1433
1434        fn get_with(
1435            &self,
1436            names: &[&str],
1437            unmatched: &[&str],
1438            on_conflict: OnConflict,
1439            dry_run: bool,
1440        ) -> GetReport {
1441            let selection = GetSelection {
1442                tracked: names.iter().map(PathBuf::from).collect::<HashSet<_>>(),
1443                unmatched: unmatched.iter().map(PathBuf::from).collect(),
1444            };
1445            get_files(
1446                selection,
1447                &self.paths,
1448                self.backend(),
1449                GetOptions {
1450                    dry_run,
1451                    on_conflict,
1452                    source: SettingSource::Argument,
1453                },
1454                &OperationContext::new(),
1455            )
1456        }
1457
1458        fn corrupt_blob(&self, name: &str) {
1459            let meta: FileMetadata = serde_json::from_slice(&self.metadata_bytes(name)).unwrap();
1460            let hash = meta.hashes.blake3;
1461            let storage = match &self.config.backend {
1462                Backend::Local(b) => b.path.clone(),
1463                Backend::Server(_) => unreachable!(),
1464            };
1465            let blob = storage.join(&hash[..2]).join(&hash[2..]);
1466            let mut perms = fs::metadata(&blob).unwrap().permissions();
1467            #[allow(clippy::permissions_set_readonly_false)]
1468            perms.set_readonly(false);
1469            fs::set_permissions(&blob, perms).unwrap();
1470            // Valid zstd of other bytes: decompression works, the hash check must catch it.
1471            fs::write(&blob, zstd::encode_all(&b"other bytes"[..], 0).unwrap()).unwrap();
1472        }
1473    }
1474
1475    fn row<'a>(report: &'a GetReport, name: &str) -> &'a GetRow {
1476        report
1477            .files
1478            .iter()
1479            .find(|r| r.path == Path::new(name))
1480            .unwrap_or_else(|| panic!("no row for {name}: {report:#?}"))
1481    }
1482
1483    // Spec 1
1484    #[test]
1485    fn absent_files_are_restored() {
1486        let repo = TestRepo::new();
1487        repo.add("a.csv", b"a");
1488        fs::remove_file(repo.root.join("a.csv")).unwrap();
1489
1490        let report = repo.get(&["a.csv"], OnConflict::Abort);
1491        assert_eq!(report.outcome, GetOutcome::Complete);
1492        let r = row(&report, "a.csv");
1493        assert_eq!(
1494            (r.before, r.action, r.satisfied),
1495            (Before::Absent, Action::Restored, true)
1496        );
1497        assert_eq!(repo.read("a.csv"), b"a");
1498    }
1499
1500    // Spec 2
1501    #[test]
1502    fn current_files_are_unchanged() {
1503        let repo = TestRepo::new();
1504        repo.add("a.csv", b"a");
1505        let report = repo.get(&["a.csv"], OnConflict::Abort);
1506        assert_eq!(report.outcome, GetOutcome::Complete);
1507        assert_eq!(row(&report, "a.csv").action, Action::Unchanged);
1508    }
1509
1510    // Spec 3 and 20: the working file is what dvs (here: add) last put there
1511    #[test]
1512    fn clean_files_after_a_branch_switch_are_replaced() {
1513        let repo = TestRepo::new();
1514        repo.switched_branch("a.csv", b"version one", b"version two");
1515
1516        let report = repo.get(&["a.csv"], OnConflict::Abort);
1517        let r = row(&report, "a.csv");
1518        assert_eq!((r.before, r.action), (Before::Clean, Action::Replaced));
1519        assert_eq!(report.outcome, GetOutcome::Complete);
1520        assert_eq!(repo.read("a.csv"), b"version one");
1521    }
1522
1523    #[test]
1524    fn a_get_install_records_the_baseline() {
1525        let repo = TestRepo::new();
1526        repo.switched_branch("a.csv", b"one", b"two");
1527        repo.get(&["a.csv"], OnConflict::Abort);
1528        // Switch "back": .dvs says two again, the working file is the one get wrote.
1529        repo.add("tmp.csv", b"two");
1530        let two = repo.metadata_bytes("tmp.csv");
1531        repo.set_metadata_bytes("a.csv", &two);
1532        let report = repo.get(&["a.csv"], OnConflict::Abort);
1533        assert_eq!(row(&report, "a.csv").before, Before::Clean, "{report:#?}");
1534        assert_eq!(repo.read("a.csv"), b"two");
1535    }
1536
1537    // Spec 4: abort changes nothing
1538    #[test]
1539    fn local_changes_abort_the_whole_batch() {
1540        let repo = TestRepo::new();
1541        repo.switched_branch("a.csv", b"a1", b"a2");
1542        repo.switched_branch("b.csv", b"b1", b"b2");
1543        fs::write(repo.root.join("b.csv"), b"my edits").unwrap();
1544
1545        let report = repo.get(&["a.csv", "b.csv"], OnConflict::Abort);
1546        assert_eq!(report.outcome, GetOutcome::Aborted);
1547        let b = row(&report, "b.csv");
1548        assert_eq!(
1549            (b.before, b.action, b.code),
1550            (
1551                Before::Modified,
1552                Action::NotRun,
1553                Some(GetCode::LocalChanges)
1554            )
1555        );
1556        assert_eq!(b.stored_copy, Some(StoredCopy::Absent));
1557        let a = row(&report, "a.csv");
1558        assert_eq!(
1559            (a.action, a.code),
1560            (Action::NotRun, Some(GetCode::BatchAborted))
1561        );
1562        assert_eq!(repo.read("a.csv"), b"a2", "nothing may change on abort");
1563        assert_eq!(repo.read("b.csv"), b"my edits");
1564        assert_eq!(report.verdict(), GetVerdict::HeldBack);
1565        assert!(b.hint.as_deref().unwrap().contains("dvs add b.csv"));
1566    }
1567
1568    // Spec 4o
1569    #[test]
1570    fn overwrite_replaces_local_changes() {
1571        let repo = TestRepo::new();
1572        repo.switched_branch("b.csv", b"b1", b"b2");
1573        fs::write(repo.root.join("b.csv"), b"my edits").unwrap();
1574
1575        let report = repo.get(&["b.csv"], OnConflict::Overwrite);
1576        assert_eq!(report.outcome, GetOutcome::Complete);
1577        let b = row(&report, "b.csv");
1578        assert_eq!((b.before, b.action), (Before::Modified, Action::Replaced));
1579        assert_eq!(repo.read("b.csv"), b"b1");
1580    }
1581
1582    // Spec 5
1583    #[test]
1584    fn a_differing_file_without_baseline_is_unknown() {
1585        let repo = TestRepo::new();
1586        repo.add("a.csv", b"tracked");
1587        fs::remove_dir_all(repo.paths.cache_folder()).unwrap();
1588        fs::write(repo.root.join("a.csv"), b"copied in by hand").unwrap();
1589
1590        let report = repo.get(&["a.csv"], OnConflict::Abort);
1591        let r = row(&report, "a.csv");
1592        assert_eq!(
1593            (r.before, r.action, r.code),
1594            (Before::Unknown, Action::NotRun, Some(GetCode::UnknownLocal))
1595        );
1596        assert_eq!(repo.read("a.csv"), b"copied in by hand");
1597    }
1598
1599    // Spec 6
1600    #[test]
1601    fn an_edit_undone_exactly_is_clean() {
1602        let repo = TestRepo::new();
1603        repo.switched_branch("a.csv", b"one", b"two");
1604        fs::write(repo.root.join("a.csv"), b"temporary edit").unwrap();
1605        fs::write(repo.root.join("a.csv"), b"two").unwrap();
1606        let report = repo.get(&["a.csv"], OnConflict::Abort);
1607        assert_eq!(row(&report, "a.csv").before, Before::Clean);
1608    }
1609
1610    // Spec 8 and 8s
1611    #[test]
1612    fn untracked_requests_are_rows_and_block_unless_skip() {
1613        let repo = TestRepo::new();
1614        repo.add("a.csv", b"a");
1615        fs::remove_file(repo.root.join("a.csv")).unwrap();
1616
1617        let report = repo.get_with(&["a.csv"], &["typo.csv"], OnConflict::Abort, false);
1618        assert_eq!(report.outcome, GetOutcome::Aborted);
1619        let t = row(&report, "typo.csv");
1620        assert_eq!(
1621            (t.before, t.code),
1622            (Before::NotTracked, Some(GetCode::NotTracked))
1623        );
1624        assert_eq!(row(&report, "a.csv").code, Some(GetCode::BatchAborted));
1625        assert!(!repo.root.join("a.csv").exists());
1626        assert_eq!(report.verdict(), GetVerdict::Failed);
1627
1628        let report = repo.get_with(&["a.csv"], &["typo.csv"], OnConflict::Skip, false);
1629        assert_eq!(report.outcome, GetOutcome::Partial);
1630        assert_eq!(row(&report, "a.csv").action, Action::Restored);
1631    }
1632
1633    // Spec 8b: a merge conflict in a .dvs file is detected before anything is written
1634    #[test]
1635    fn malformed_metadata_blocks_the_batch_unless_skip() {
1636        let repo = TestRepo::new();
1637        repo.add("a.csv", b"a");
1638        repo.add("b.csv", b"b");
1639        fs::remove_file(repo.root.join("a.csv")).unwrap();
1640        repo.set_metadata_bytes("b.csv", b"<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> other\n");
1641
1642        let report = repo.get(&["a.csv", "b.csv"], OnConflict::Abort);
1643        assert_eq!(report.outcome, GetOutcome::Aborted);
1644        assert_eq!(
1645            row(&report, "b.csv").code,
1646            Some(GetCode::Shared(ErrorCode::BadMetadata))
1647        );
1648        assert_eq!(row(&report, "a.csv").code, Some(GetCode::BatchAborted));
1649        assert!(!repo.root.join("a.csv").exists());
1650
1651        let report = repo.get(&["a.csv", "b.csv"], OnConflict::Skip);
1652        assert_eq!(row(&report, "a.csv").action, Action::Restored);
1653    }
1654
1655    #[test]
1656    fn a_directory_without_selected_files_says_so() {
1657        let repo = TestRepo::new();
1658        repo.add("a.csv", b"a");
1659        fs::create_dir_all(repo.root.join("empty")).unwrap();
1660        let report = repo.get_with(&["a.csv"], &["empty"], OnConflict::Skip, false);
1661        let r = row(&report, "empty");
1662        assert_eq!(r.code, Some(GetCode::NotTracked));
1663        assert_eq!(
1664            r.message.as_deref(),
1665            Some("no tracked files selected in this directory")
1666        );
1667    }
1668
1669    // Spec 9
1670    #[test]
1671    fn an_empty_selection_is_an_operation_problem() {
1672        let repo = TestRepo::new();
1673        let report = repo.get(&[], OnConflict::Abort);
1674        assert_eq!(report.outcome, GetOutcome::Failed);
1675        assert_eq!(
1676            report.problem.map(|p| p.code),
1677            Some(ErrorCode::NoFilesSelected)
1678        );
1679        assert!(report.files.is_empty());
1680    }
1681
1682    // Spec 10
1683    #[test]
1684    fn missing_content_fails_only_that_file() {
1685        let repo = TestRepo::new();
1686        repo.add("a.csv", b"a");
1687        repo.add("b.csv", b"b");
1688        let storage = match &repo.config.backend {
1689            Backend::Local(b) => b.path.clone(),
1690            Backend::Server(_) => unreachable!(),
1691        };
1692        let meta: FileMetadata = serde_json::from_slice(&repo.metadata_bytes("b.csv")).unwrap();
1693        let h = meta.hashes.blake3;
1694        fs::remove_file(storage.join(&h[..2]).join(&h[2..])).unwrap();
1695        fs::remove_file(repo.root.join("a.csv")).unwrap();
1696        fs::remove_file(repo.root.join("b.csv")).unwrap();
1697
1698        let report = repo.get(&["a.csv", "b.csv"], OnConflict::Abort);
1699        assert_eq!(report.outcome, GetOutcome::Partial);
1700        assert_eq!(row(&report, "a.csv").action, Action::Restored);
1701        assert_eq!(row(&report, "b.csv").code, Some(GetCode::SourceMissing));
1702        let c = &report.counts;
1703        assert_eq!((c.total, c.succeeded, c.failed), (2, 1, 1));
1704    }
1705
1706    // Spec 11
1707    #[test]
1708    fn corrupt_content_keeps_the_existing_file() {
1709        let repo = TestRepo::new();
1710        repo.switched_branch("a.csv", b"original content", b"newer content");
1711        // switched_branch's first add stored "original content": corrupt that one.
1712        repo.corrupt_blob("a.csv");
1713        let report = repo.get(&["a.csv"], OnConflict::Abort);
1714        let r = row(&report, "a.csv");
1715        assert_eq!(
1716            (r.action, r.code),
1717            (Action::Failed, Some(GetCode::HashMismatch))
1718        );
1719        assert_eq!(repo.read("a.csv"), b"newer content");
1720        let tmp_left = fs::read_dir(&repo.root)
1721            .unwrap()
1722            .flatten()
1723            .any(|e| e.file_name().to_string_lossy().contains("dvs-tmp"));
1724        assert!(!tmp_left);
1725    }
1726
1727    // Spec 13
1728    #[test]
1729    fn a_directory_in_the_way_is_never_replaced() {
1730        let repo = TestRepo::new();
1731        repo.add("a.csv", b"a");
1732        fs::remove_file(repo.root.join("a.csv")).unwrap();
1733        fs::create_dir(repo.root.join("a.csv")).unwrap();
1734        fs::write(repo.root.join("a.csv/inside"), b"keep").unwrap();
1735
1736        let report = repo.get(&["a.csv"], OnConflict::Overwrite);
1737        assert_eq!(row(&report, "a.csv").code, Some(GetCode::NotAFile));
1738        assert!(repo.root.join("a.csv/inside").exists());
1739    }
1740
1741    // Spec 13, symlink at the destination
1742    #[cfg(unix)]
1743    #[test]
1744    fn a_symlinked_destination_is_never_written_through() {
1745        let repo = TestRepo::new();
1746        repo.add("a.csv", b"a");
1747        let elsewhere = repo.root.parent().unwrap().join("elsewhere.csv");
1748        fs::write(&elsewhere, b"precious").unwrap();
1749        fs::remove_file(repo.root.join("a.csv")).unwrap();
1750        std::os::unix::fs::symlink(&elsewhere, repo.root.join("a.csv")).unwrap();
1751
1752        let report = repo.get(&["a.csv"], OnConflict::Overwrite);
1753        assert_eq!(row(&report, "a.csv").code, Some(GetCode::NotAFile));
1754        assert_eq!(fs::read(&elsewhere).unwrap(), b"precious");
1755    }
1756
1757    // Spec 14
1758    #[cfg(unix)]
1759    #[test]
1760    fn a_symlinked_parent_leading_outside_is_refused() {
1761        let repo = TestRepo::new();
1762        repo.add("data/x.txt", b"tracked");
1763        let outside = repo.root.parent().unwrap().join("outside");
1764        fs::create_dir(&outside).unwrap();
1765        fs::write(outside.join("x.txt"), b"precious").unwrap();
1766        fs::remove_dir_all(repo.root.join("data")).unwrap();
1767        std::os::unix::fs::symlink(&outside, repo.root.join("data")).unwrap();
1768
1769        for policy in [OnConflict::Abort, OnConflict::Skip, OnConflict::Overwrite] {
1770            let report = repo.get(&["data/x.txt"], policy);
1771            let r = row(&report, "data/x.txt");
1772            assert_eq!(
1773                (r.before, r.code),
1774                (Before::UnsafePath, Some(GetCode::UnsafePath))
1775            );
1776            assert_eq!(fs::read(outside.join("x.txt")).unwrap(), b"precious");
1777        }
1778    }
1779
1780    // Spec 15: the recheck itself
1781    #[test]
1782    fn a_destination_that_changed_after_planning_is_detected() {
1783        let repo = TestRepo::new();
1784        let path = create_file(&repo.root, "a.csv", b"before");
1785        let stat = Some(FileStat::from_path(&path).unwrap());
1786        assert!(destination_unchanged(&path, &stat));
1787        fs::write(&path, b"edited meanwhile").unwrap();
1788        assert!(!destination_unchanged(&path, &stat));
1789        let absent = repo.root.join("new.csv");
1790        assert!(destination_unchanged(&absent, &None));
1791        fs::write(&absent, b"appeared").unwrap();
1792        assert!(!destination_unchanged(&absent, &None));
1793    }
1794
1795    // Spec 17
1796    #[test]
1797    fn a_dry_run_writes_nothing() {
1798        let repo = TestRepo::new();
1799        repo.switched_branch("a.csv", b"a1", b"a2");
1800        repo.add("b.csv", b"b");
1801        fs::remove_file(repo.root.join("b.csv")).unwrap();
1802
1803        let report = repo.get_with(&["a.csv", "b.csv"], &[], OnConflict::Abort, true);
1804        assert_eq!(report.outcome, GetOutcome::Complete);
1805        assert_eq!(row(&report, "a.csv").action, Action::WouldReplace);
1806        assert_eq!(row(&report, "b.csv").action, Action::WouldRestore);
1807        assert_eq!(repo.read("a.csv"), b"a2");
1808        assert!(!repo.root.join("b.csv").exists());
1809    }
1810
1811    // Spec 4s and 21
1812    #[test]
1813    fn skip_keeps_local_changes_and_gets_the_rest() {
1814        let repo = TestRepo::new();
1815        repo.add("a.csv", b"a");
1816        fs::remove_file(repo.root.join("a.csv")).unwrap();
1817        repo.switched_branch("b.csv", b"b1", b"b2");
1818        fs::write(repo.root.join("b.csv"), b"edited").unwrap();
1819        repo.add("c.csv", b"c");
1820        repo.switched_branch("d.csv", b"d1", b"d2");
1821
1822        let report = repo.get(&["a.csv", "b.csv", "c.csv", "d.csv"], OnConflict::Skip);
1823        assert_eq!(row(&report, "a.csv").action, Action::Restored);
1824        assert_eq!(row(&report, "b.csv").action, Action::Skipped);
1825        assert_eq!(row(&report, "c.csv").action, Action::Unchanged);
1826        assert_eq!(row(&report, "d.csv").action, Action::Replaced);
1827        assert_eq!(report.outcome, GetOutcome::Partial);
1828        assert_eq!(report.verdict(), GetVerdict::Done);
1829        assert_eq!(repo.read("b.csv"), b"edited");
1830        assert_eq!(repo.read("d.csv"), b"d1");
1831    }
1832
1833    #[test]
1834    fn skipping_every_file_is_partial_and_done() {
1835        let repo = TestRepo::new();
1836        repo.switched_branch("b.csv", b"b1", b"b2");
1837        fs::write(repo.root.join("b.csv"), b"edited").unwrap();
1838
1839        let report = repo.get(&["b.csv"], OnConflict::Skip);
1840        assert_eq!(row(&report, "b.csv").action, Action::Skipped);
1841        assert_eq!(report.outcome, GetOutcome::Partial);
1842        assert_eq!(report.verdict(), GetVerdict::Done);
1843    }
1844
1845    // Run from a subdirectory: paths in the report are from the project root
1846    // and from the cwd, also for requests that match nothing, absolute or not.
1847    #[test]
1848    fn paths_from_a_subdirectory() {
1849        let repo = TestRepo::new();
1850        repo.add("data/x.csv", b"x");
1851        fs::remove_file(repo.root.join("data/x.csv")).unwrap();
1852        let in_data = DvsPaths::new(
1853            repo.root.join("data"),
1854            repo.root.clone(),
1855            repo.config.metadata_folder_name(),
1856        )
1857        .unwrap();
1858        let selection = GetSelection {
1859            tracked: HashSet::from([PathBuf::from("data/x.csv")]),
1860            unmatched: vec!["typo.csv".into(), repo.root.join("data/abs-typo.csv")],
1861        };
1862        let options = GetOptions {
1863            dry_run: false,
1864            on_conflict: OnConflict::Skip,
1865            source: SettingSource::Argument,
1866        };
1867        let report = get_files(
1868            selection,
1869            &in_data,
1870            repo.backend(),
1871            options,
1872            &OperationContext::new(),
1873        );
1874        let local = |name: &str| row(&report, name).local_path.clone();
1875        assert_eq!(local("data/x.csv"), PathBuf::from("x.csv"));
1876        assert_eq!(local("data/typo.csv"), PathBuf::from("typo.csv"));
1877        assert_eq!(local("data/abs-typo.csv"), PathBuf::from("abs-typo.csv"));
1878        assert!(repo.root.join("data/x.csv").exists());
1879    }
1880
1881    // Upgrading: an existing cache db without baseline rows. Changed files are
1882    // unknown until a get sees them current; dry runs never record anything.
1883    #[cfg(feature = "cache")]
1884    #[test]
1885    fn an_upgraded_clone_learns_the_baseline_from_a_real_get() {
1886        let repo = TestRepo::new();
1887        repo.add("a.csv", b"one");
1888        let one = repo.metadata_bytes("a.csv");
1889        repo.add("a.csv", b"two");
1890        let two = repo.metadata_bytes("a.csv");
1891        rusqlite::Connection::open(repo.paths.cache_folder().join("dvs.db"))
1892            .unwrap()
1893            .execute_batch("DROP TABLE baseline;")
1894            .unwrap();
1895
1896        let report = repo.get_with(&["a.csv"], &[], OnConflict::Abort, true);
1897        assert_eq!(row(&report, "a.csv").action, Action::Unchanged);
1898        repo.set_metadata_bytes("a.csv", &one);
1899        let report = repo.get(&["a.csv"], OnConflict::Abort);
1900        assert_eq!(
1901            row(&report, "a.csv").before,
1902            Before::Unknown,
1903            "dry run recorded"
1904        );
1905
1906        repo.set_metadata_bytes("a.csv", &two);
1907        repo.get(&["a.csv"], OnConflict::Abort);
1908        repo.set_metadata_bytes("a.csv", &one);
1909        let report = repo.get(&["a.csv"], OnConflict::Abort);
1910        let r = row(&report, "a.csv");
1911        assert_eq!((r.before, r.action), (Before::Clean, Action::Replaced));
1912        assert_eq!(repo.read("a.csv"), b"one");
1913    }
1914
1915    // Spec 16: a destination that can't be written fails alone, keeps what
1916    // was there, and a rerun finishes once it is fixed.
1917    #[cfg(unix)]
1918    #[test]
1919    fn an_unwritable_destination_fails_alone_and_a_rerun_finishes() {
1920        use std::os::unix::fs::PermissionsExt;
1921        let repo = TestRepo::new();
1922        repo.add("locked/a.csv", b"a");
1923        repo.add("b.csv", b"b");
1924        fs::remove_file(repo.root.join("locked/a.csv")).unwrap();
1925        fs::remove_file(repo.root.join("b.csv")).unwrap();
1926        let locked = repo.root.join("locked");
1927        fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
1928        if fs::write(locked.join("probe"), b"").is_ok() {
1929            return; // running as root: permissions are not enforced
1930        }
1931
1932        let report = repo.get(&["locked/a.csv", "b.csv"], OnConflict::Abort);
1933        fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
1934        assert_eq!(report.outcome, GetOutcome::Partial);
1935        assert_eq!(
1936            row(&report, "locked/a.csv").code,
1937            Some(GetCode::PermissionDenied)
1938        );
1939        assert_eq!(row(&report, "b.csv").action, Action::Restored);
1940
1941        let report = repo.get(&["locked/a.csv", "b.csv"], OnConflict::Abort);
1942        assert_eq!(report.outcome, GetOutcome::Complete);
1943        assert_eq!(repo.read("locked/a.csv"), b"a");
1944    }
1945
1946    impl TestRepo {
1947        fn get_observed(
1948            &self,
1949            names: &[&str],
1950            unmatched: &[&str],
1951        ) -> (GetReport, Vec<String>, Vec<serde_json::Value>) {
1952            let recorder = std::sync::Arc::new(crate::testutil::Recorder::default());
1953            let selection = GetSelection {
1954                tracked: names.iter().map(PathBuf::from).collect::<HashSet<_>>(),
1955                unmatched: unmatched.iter().map(PathBuf::from).collect(),
1956            };
1957            let options = GetOptions {
1958                dry_run: false,
1959                on_conflict: OnConflict::Skip,
1960                source: SettingSource::Argument,
1961            };
1962            let ctx = OperationContext::new().with_observer(recorder.clone());
1963            let report = get_files(selection, &self.paths, self.backend(), options, &ctx);
1964            let rows = recorder.rows.lock().unwrap().clone();
1965            (report, recorder.seen(), rows)
1966        }
1967
1968        fn temp_files_left(&self) -> bool {
1969            fs::read_dir(&self.root)
1970                .unwrap()
1971                .flatten()
1972                .any(|e| e.file_name().to_string_lossy().contains("dvs-tmp"))
1973        }
1974    }
1975
1976    #[test]
1977    fn observer_hears_the_plan_then_each_final_row_once_as_it_finishes() {
1978        let repo = TestRepo::new();
1979        // Restored, unchanged, skipped (local changes), restored, untracked
1980        repo.add("a.csv", b"aaa");
1981        fs::remove_file(repo.root.join("a.csv")).unwrap();
1982        repo.add("b.csv", b"bb");
1983        repo.switched_branch("c.csv", b"c1", b"c2");
1984        fs::write(repo.root.join("c.csv"), b"edited").unwrap();
1985        repo.add("d.csv", b"dddd");
1986        fs::remove_file(repo.root.join("d.csv")).unwrap();
1987        let names = ["a.csv", "b.csv", "c.csv", "d.csv"];
1988        let (report, seen, mut rows) = repo.get_observed(&names, &["typo.csv"]);
1989
1990        let at = |line: &str| seen.iter().position(|s| s == line).unwrap();
1991        // a (3) and d (4) to download of 11 bytes; typo has no size
1992        let plan = at("plan 5 11 2 7");
1993        assert!(
1994            seen[..plan].iter().all(|s| !s.starts_with("row")),
1995            "{seen:?}"
1996        );
1997        // Each row once, exactly as in the result (hint included)
1998        rows.sort_by_key(|r| r["path"].as_str().unwrap().to_string());
1999        let expected: Vec<_> = report
2000            .files
2001            .iter()
2002            .map(|r| serde_json::to_value(r).unwrap())
2003            .collect();
2004        assert_eq!(rows, expected);
2005        assert!(row(&report, "c.csv").hint.is_some());
2006        // Rows that did work carry their time
2007        assert!(row(&report, "a.csv").duration_ms.is_some());
2008        assert!(row(&report, "b.csv").duration_ms.is_some());
2009        assert_eq!(row(&report, "typo.csv").duration_ms, None);
2010    }
2011
2012    #[test]
2013    fn no_hash_events_when_nothing_is_hashed() {
2014        let repo = TestRepo::new();
2015        repo.add("a.csv", b"a");
2016        repo.add("b.csv", b"b");
2017        fs::remove_file(repo.root.join("b.csv")).unwrap();
2018        let hash_events = |name| {
2019            let (_, seen, _) = repo.get_observed(&[name], &[]);
2020            seen.into_iter()
2021                .filter(|s| s.starts_with("hash"))
2022                .collect::<Vec<_>>()
2023        };
2024        // Present: hashed
2025        assert_eq!(hash_events("a.csv"), ["hash start", "hash end"]);
2026        // Absent: nothing to hash
2027        assert!(hash_events("b.csv").is_empty());
2028    }
2029
2030    #[test]
2031    fn a_cancel_lets_started_files_finish_and_cancels_the_rest() {
2032        let repo = TestRepo::new();
2033        // More files than the pool can have threads, so some never start.
2034        let names: Vec<String> = (0..40).map(|i| format!("f{i:02}.csv")).collect();
2035        for name in &names {
2036            repo.add(name, name.as_bytes());
2037            fs::remove_file(repo.root.join(name)).unwrap();
2038        }
2039        let token = crate::CancelToken::new();
2040        let recorder = std::sync::Arc::new(crate::testutil::Recorder::cancelling_on_transfer(
2041            token.clone(),
2042            1,
2043        ));
2044        let selection = GetSelection {
2045            tracked: names.iter().map(PathBuf::from).collect(),
2046            unmatched: Vec::new(),
2047        };
2048        let options = GetOptions {
2049            dry_run: false,
2050            on_conflict: OnConflict::Abort,
2051            source: SettingSource::Argument,
2052        };
2053        let ctx = OperationContext::new()
2054            .with_cancel(token)
2055            .with_observer(recorder.clone());
2056        let report = get_files(selection, &repo.paths, repo.backend(), options, &ctx);
2057
2058        assert_eq!(report.outcome, GetOutcome::Cancelled);
2059        let seen = recorder.seen();
2060        let started: HashSet<_> = seen
2061            .iter()
2062            .filter_map(|s| s.strip_prefix("transfer "))
2063            .map(PathBuf::from)
2064            .collect();
2065        assert!(!started.is_empty());
2066        let mut cancelled = 0;
2067        for r in &report.files {
2068            if started.contains(&r.path) {
2069                assert_eq!((r.action, r.code), (Action::Restored, None));
2070                assert!(repo.root.join(&r.path).exists());
2071            } else {
2072                assert_eq!(
2073                    (r.action, r.code, r.satisfied, r.duration_ms),
2074                    (
2075                        Action::Cancelled,
2076                        Some(GetCode::Shared(ErrorCode::Cancelled)),
2077                        false,
2078                        None
2079                    )
2080                );
2081                assert!(!repo.root.join(&r.path).exists());
2082                cancelled += 1;
2083            }
2084        }
2085        assert!(cancelled > 0, "the cancel came too late to test anything");
2086        assert_eq!(report.counts.cancelled, cancelled);
2087        assert_eq!(report.counts.not_run, 0);
2088        assert_eq!(report.counts.restored, started.len());
2089        assert_eq!(report.counts.failed, 0, "cancelled files are not failures");
2090        assert!(!repo.temp_files_left());
2091        // Cancelled rows are reported too, once each.
2092        assert_eq!(recorder.rows.lock().unwrap().len(), names.len());
2093    }
2094
2095    #[test]
2096    fn access_failures_keep_their_shared_code() {
2097        for code in [ErrorCode::AuthExpired, ErrorCode::ProjectNotFound] {
2098            let e: anyhow::Error = Error::new(code, "nope").into();
2099            assert_eq!(access_failure(e).0.as_str(), code.as_str());
2100        }
2101        let plain = anyhow::anyhow!("something went wrong");
2102        assert_eq!(
2103            access_failure(plain).0,
2104            GetCode::Shared(ErrorCode::ServerUnavailable)
2105        );
2106    }
2107
2108    #[test]
2109    fn codes_serialize_as_flat_strings() {
2110        let json = |code: GetCode| serde_json::to_value(code).unwrap();
2111        assert_eq!(json(GetCode::LocalChanges), "local_changes");
2112        assert_eq!(json(GetCode::Shared(ErrorCode::IoError)), "io_error");
2113    }
2114
2115    /// A report with no rows, its outcome and counts set by hand.
2116    fn summary_report(outcome: GetOutcome, counts: GetCounts) -> GetReport {
2117        let mut report = GetReport::problem(
2118            ErrorCode::OperationFailed,
2119            "",
2120            false,
2121            OnConflict::Abort,
2122            SettingSource::Default,
2123        );
2124        report.problem = None;
2125        report.outcome = outcome;
2126        report.counts = counts;
2127        report
2128    }
2129
2130    #[test]
2131    fn aborted_summary_names_files_that_cannot_be_retrieved() {
2132        let mut report = summary_report(
2133            GetOutcome::Aborted,
2134            GetCounts {
2135                failed: 1,
2136                ..GetCounts::default()
2137            },
2138        );
2139        assert!(report.summary().contains("1 file cannot be retrieved"));
2140        report.counts.failed = 2;
2141        assert!(report.summary().contains("2 files cannot be retrieved"));
2142    }
2143
2144    #[test]
2145    fn summary_lists_the_non_zero_counts_in_order() {
2146        let report = summary_report(
2147            GetOutcome::Cancelled,
2148            GetCounts {
2149                restored: 1,
2150                cancelled: 2,
2151                ..GetCounts::default()
2152            },
2153        );
2154        assert_eq!(report.summary(), "Cancelled: restored 1, cancelled 2");
2155        let report = summary_report(
2156            GetOutcome::Complete,
2157            GetCounts {
2158                unchanged: 3,
2159                ..GetCounts::default()
2160            },
2161        );
2162        assert_eq!(report.summary(), "Nothing to do, 3 already current");
2163    }
2164}