Skip to main content
← dvs documentation Rust API reference

dvs/files/
add.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use anyhow::{Result, anyhow};
6use fs_err as fs;
7use rayon::iter::Either;
8use rayon::prelude::*;
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::backends::Backend;
13use crate::backends::server::{UploadManifest, UploadManifestEntry};
14use crate::cache::try_open_cache;
15use crate::config::Compression;
16use crate::error::{ErrorCode, backend_error_code};
17use crate::files::metadata::FileMetadata;
18use crate::gitignore::add_to_gitignore;
19use crate::operation::{
20    ByteTotals, Counts, FileRow, Hashing, OperationContext, OperationObserver, OperationPlan,
21    OperationRow, RowState, RunOutcome, duration_ms, millis,
22};
23use crate::paths::{DvsPaths, ProjectPath};
24use crate::utils::get_threadpool;
25use crate::{Outcome, cache};
26
27/// Result of adding a single file.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AddResult {
30    pub path: PathBuf,
31    #[serde(flatten)]
32    pub detail: AddDetail,
33    /// Time spent on this file (hash + transfer + record); `None` when it did
34    /// no work (including rows cancelled before hashing; a row cancelled after
35    /// hashing carries its work so far).
36    pub duration_ms: Option<u64>,
37}
38
39impl OperationRow for AddResult {
40    fn state(&self) -> RowState {
41        match self.detail {
42            AddDetail::Success { .. } => RowState::Succeeded,
43            AddDetail::Error { .. } => RowState::Failed,
44            AddDetail::Cancelled { .. } => RowState::Cancelled,
45        }
46    }
47}
48
49/// The rows of an `add`, sorted by path, plus counts, outcome and byte totals.
50#[derive(Debug, Clone)]
51pub struct AddReport {
52    pub files: Vec<AddResult>,
53    pub counts: Counts,
54    pub outcome: RunOutcome,
55    pub bytes: ByteTotals,
56}
57
58impl AddReport {
59    fn new(files: Vec<AddResult>, bytes: ByteTotals, cancelled: bool) -> Self {
60        let counts = Counts::of(&files);
61        AddReport {
62            outcome: RunOutcome::of(&counts, cancelled),
63            files,
64            counts,
65            bytes,
66        }
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(untagged)]
72pub enum AddDetail {
73    Success {
74        outcome: Outcome,
75        hash: String,
76        size: u64,
77        stored_size: u64,
78    },
79    Error {
80        code: ErrorCode,
81        error: String,
82    },
83    Cancelled {
84        message: String,
85    },
86}
87
88fn add_error_code(error: &anyhow::Error) -> ErrorCode {
89    if let Some(code) = backend_error_code(error) {
90        code
91    } else if error.downcast_ref::<std::io::Error>().is_some() {
92        ErrorCode::IoError
93    } else {
94        ErrorCode::OperationFailed
95    }
96}
97
98fn error_row(path: PathBuf, error: &anyhow::Error, duration_ms: Option<u64>) -> AddResult {
99    log::warn!("Failed to add {}: {error}", path.display());
100    AddResult {
101        path,
102        detail: AddDetail::Error {
103            code: add_error_code(error),
104            error: error.to_string(),
105        },
106        duration_ms,
107    }
108}
109
110/// A file that was not added because the operation was cancelled: no upload,
111/// no record. `work` is the time it already spent (its hashing), `None` when
112/// it was cancelled before it was hashed.
113fn cancelled_row(path: PathBuf, work: Option<Duration>) -> AddResult {
114    AddResult {
115        path,
116        detail: AddDetail::Cancelled {
117            message: "cancelled before this file was added".to_string(),
118        },
119        duration_ms: work.map(millis),
120    }
121}
122
123/// A file to add: the metadata it will get, waiting on the backend.
124struct Pending {
125    relative_path: PathBuf,
126    metadata: FileMetadata,
127    stored: bool,
128    /// Time spent on this file so far: hashing, then storage and record
129    work: Duration,
130}
131
132impl Pending {
133    /// Whether the metadata file on disk already says exactly this
134    fn in_sync(&self, paths: &DvsPaths) -> bool {
135        let Ok(file) = fs::File::open(paths.metadata_path(&self.relative_path)) else {
136            return false;
137        };
138        serde_json::from_reader::<_, FileMetadata>(file)
139            .is_ok_and(|existing| existing == self.metadata)
140    }
141
142    fn manifest_entry(&self) -> Result<UploadManifestEntry> {
143        Ok(UploadManifestEntry {
144            path: ProjectPath::from_path(&self.relative_path).map_err(|e| anyhow!("{e}"))?,
145            size: self.metadata.size,
146            hash: self.metadata.hashes.blake3.clone(),
147        })
148    }
149
150    /// Once hashed the size is known, so a later failure or cancel still counts it.
151    fn row_bytes(&self, transferred: bool) -> RowBytes {
152        RowBytes {
153            size: self.metadata.size,
154            stored: self.stored,
155            transferred,
156        }
157    }
158}
159
160struct RowBytes {
161    size: u64,
162    stored: bool,
163    transferred: bool,
164}
165
166/// Whether a file that has not started yet is cancelled. The server records
167/// every manifest entry whose content it already has, so after a cancel those
168/// files still write their local record, keeping the two in agreement.
169fn cancelled_before_start(
170    cancelled: bool,
171    hash: &str,
172    manifest_missing: Option<&HashSet<String>>,
173) -> bool {
174    cancelled && !manifest_missing.is_some_and(|missing| !missing.contains(hash))
175}
176
177struct Added {
178    outcome: Outcome,
179    stored_size: Option<u64>,
180    /// The backend created the blob, i.e. this file's bytes moved
181    created: bool,
182}
183
184/// `missing` is the server's answer to the manifest: the hashes to upload. The local
185/// backend has no manifest and checks for itself. Only a store moves bytes, so
186/// only it is reported as a transfer.
187#[allow(clippy::too_many_arguments)]
188fn add_file(
189    pending: &mut Pending,
190    paths: &DvsPaths,
191    backend: &Backend,
192    operation_id: Uuid,
193    missing: Option<&HashSet<String>>,
194    dry_run: bool,
195    observer: &dyn OperationObserver,
196) -> Result<Added> {
197    let full_path = paths.file_path(&pending.relative_path);
198    if dry_run {
199        let outcome = if pending.stored && pending.in_sync(paths) {
200            Outcome::Present
201        } else {
202            Outcome::Copied
203        };
204        return Ok(Added {
205            outcome,
206            stored_size: None,
207            created: false,
208        });
209    }
210    let store = match missing {
211        None => !pending
212            .metadata
213            .is_saved(backend, paths, &pending.relative_path)?,
214        Some(missing) => missing.contains(&pending.metadata.hashes.blake3),
215    };
216    if !store {
217        // In sync, or the server has the content and recorded the add from the manifest
218        let outcome = if missing.is_none() || pending.in_sync(paths) {
219            Outcome::Present
220        } else {
221            pending.metadata.write(paths, &pending.relative_path)?;
222            Outcome::Copied
223        };
224        return Ok(Added {
225            outcome,
226            stored_size: None,
227            created: false,
228        });
229    }
230    let file_progress = observer.transfer_started(&pending.relative_path, pending.metadata.size);
231    let res = pending.metadata.store_and_write(
232        operation_id,
233        &full_path,
234        backend,
235        paths,
236        &pending.relative_path,
237        file_progress.as_ref().map(|fp| &*fp.on_bytes),
238    );
239    if let Some(fp) = &file_progress {
240        (fp.on_done)(res.is_ok());
241    }
242    let res = res?;
243    Ok(Added {
244        outcome: Outcome::Copied,
245        stored_size: Some(res.stored_size),
246        created: res.created,
247    })
248}
249
250/// Adds files matching a glob pattern to DVS.
251///
252/// The pattern is matched against files relative to cwd.
253/// Files are stored with paths relative to repo_root.
254///
255/// Reports to `ctx`'s observer as it goes: hashing (if any file is hashed,
256/// finished also on an error exit), the plan (once storage has
257/// been asked what it has, and always before any row), each transfer, and
258/// each row as soon as it is final. Once `ctx` is cancelled no new file
259/// starts: files in flight finish (upload and record together), the rest get
260/// a `cancelled` row and no record, except files whose add the server already
261/// recorded (see [`cancelled_before_start`]). A run that fails after a cancel
262/// still returns its (empty) cancelled report.
263pub fn add_files(
264    files: Vec<PathBuf>,
265    paths: &DvsPaths,
266    backend: &Backend,
267    message: Option<String>,
268    compression: Compression,
269    dry_run: bool,
270    ctx: &OperationContext,
271) -> Result<AddReport> {
272    match add_all(files, paths, backend, message, compression, dry_run, ctx) {
273        Err(_) if ctx.is_cancelled() => Ok(AddReport::new(Vec::new(), ByteTotals::default(), true)),
274        result => result,
275    }
276}
277
278fn add_all(
279    files: Vec<PathBuf>,
280    paths: &DvsPaths,
281    backend: &Backend,
282    message: Option<String>,
283    compression: Compression,
284    dry_run: bool,
285    ctx: &OperationContext,
286) -> Result<AddReport> {
287    let observer = ctx.observer();
288    let matched_paths = paths.validate_for_add(&files);
289    if matched_paths.is_empty() {
290        // Still planned, once: nothing to add, and no rows follow
291        observer.planned(&OperationPlan::default());
292        return Ok(AddReport::new(
293            Vec::new(),
294            ByteTotals::default(),
295            ctx.is_cancelled(),
296        ));
297    }
298
299    // If any input is invalid we just bail instead of doing partial execution
300    let invalid = matched_paths
301        .iter()
302        .filter_map(|(path, status)| {
303            status
304                .reason()
305                .map(|reason| format!("  {}: {reason}", path.display()))
306        })
307        .collect::<Vec<_>>();
308    if !invalid.is_empty() {
309        return Err(crate::Error::new(
310            ErrorCode::InvalidArguments,
311            format!(
312                "Refusing to add, the following paths are invalid:\n{}",
313                invalid.join("\n")
314            ),
315        )
316        .into());
317    }
318
319    let valid_paths = matched_paths
320        .into_iter()
321        .map(|(path, _)| path)
322        .collect::<Vec<_>>();
323
324    // Fail fast on auth/permission problems before hashing, which can take long
325    backend.check_access()?;
326
327    let files_total = valid_paths.len() as u64;
328    let pool = get_threadpool(valid_paths.len())?;
329    let cache = try_open_cache(paths);
330    let operation_id = Uuid::new_v4();
331
332    // Rows final before the plan, reported only once it is, so that `planned`
333    // precedes every row. `None`: size unknown.
334    let mut results: Vec<(AddResult, Option<RowBytes>)>;
335
336    // 1. Hash everything first: the server wants the whole list before any bytes move
337    let hashing = Hashing::new(observer);
338    let mut pending: Vec<Pending>;
339    (pending, results) = pool.install(|| {
340        valid_paths.into_par_iter().partition_map(|relative_path| {
341            if ctx.is_cancelled() {
342                return Either::Right((cancelled_row(relative_path, None), None));
343            }
344            hashing.begin();
345            let start = Instant::now();
346            let full_path = paths.file_path(&relative_path);
347            let rel_str = relative_path.to_string_lossy();
348            match cache::hashes_for_file(&full_path, &rel_str, cache.as_ref()) {
349                Ok((hashes, size)) => Either::Left(Pending {
350                    metadata: FileMetadata::from_hashes(hashes, size, compression, message.clone()),
351                    relative_path,
352                    stored: false,
353                    work: start.elapsed(),
354                }),
355                Err(e) => {
356                    Either::Right((error_row(relative_path, &e, Some(duration_ms(start))), None))
357                }
358            }
359        })
360    });
361    hashing.finish();
362
363    // 2. Ask storage what it already has: the server through the manifest (minus
364    // files already in sync), other backends and dry runs file by file. The
365    // server records every manifest entry, so a cancel before this sends none
366    let mut missing = None;
367    let mut storage_asked = false;
368    if !ctx.is_cancelled() {
369        match backend {
370            Backend::Server(server) if !dry_run => {
371                let entries = pending
372                    .iter()
373                    .filter(|p| !p.in_sync(paths))
374                    .map(Pending::manifest_entry)
375                    .collect::<Result<Vec<_>>>()?;
376                let checkout = crate::git::current_checkout(paths.repo_root());
377                let mut answer_missing = HashSet::new();
378                // Everything in sync: no manifest, so no storage check either
379                if !entries.is_empty() {
380                    let answer = server.manifest(&UploadManifest {
381                        operation_id,
382                        message: message.clone(),
383                        git_branch: checkout.branch.clone(),
384                        git_commit: checkout.commit.clone(),
385                        entries,
386                    })?;
387                    // The metadata files record the project's compression, not the one asked for
388                    for p in &mut pending {
389                        p.metadata.compression = answer.compression;
390                    }
391                    answer_missing = answer.missing.into_iter().collect();
392                }
393                for p in &mut pending {
394                    // In sync files were not in the manifest, but there is
395                    // nothing left to do for them either
396                    p.stored = !answer_missing.contains(&p.metadata.hashes.blake3);
397                }
398                missing = Some(answer_missing);
399            }
400            _ => {
401                let (checked, unchecked): (Vec<Pending>, Vec<_>) = pool.install(|| {
402                    pending.into_par_iter().partition_map(|mut p| {
403                        if ctx.is_cancelled() {
404                            let bytes = p.row_bytes(false);
405                            let row = cancelled_row(p.relative_path, Some(p.work));
406                            return Either::Right((row, Some(bytes)));
407                        }
408                        let start = Instant::now();
409                        match backend.exists(&p.metadata.hashes) {
410                            Ok(stored) => {
411                                p.stored = stored;
412                                p.work += start.elapsed();
413                                Either::Left(p)
414                            }
415                            Err(e) => {
416                                let bytes = p.row_bytes(false);
417                                let work = p.work + start.elapsed();
418                                let row = error_row(p.relative_path, &e, Some(millis(work)));
419                                Either::Right((row, Some(bytes)))
420                            }
421                        }
422                    })
423                });
424                pending = checked;
425                results.extend(unchecked);
426            }
427        }
428        storage_asked = true;
429    }
430
431    // Nothing is known to need uploading until storage has been asked
432    let to_transfer = pending.iter().filter(|p| storage_asked && !p.stored);
433    let known_sizes = results.iter().filter_map(|(_, bytes)| bytes.as_ref());
434    observer.planned(&OperationPlan {
435        files_total,
436        bytes_total: pending.iter().map(|p| p.metadata.size).sum::<u64>()
437            + known_sizes.map(|b| b.size).sum::<u64>(),
438        files_to_transfer: to_transfer.clone().count() as u64,
439        bytes_to_transfer: to_transfer.map(|p| p.metadata.size).sum(),
440    });
441    for (row, _) in &results {
442        observer.file_finished(FileRow::Add(row));
443    }
444
445    // 3. Store what is missing and write the metadata files
446    let finish_one = |mut pending: Pending| -> (AddResult, Option<RowBytes>) {
447        let hash = &pending.metadata.hashes.blake3;
448        if cancelled_before_start(ctx.is_cancelled(), hash, missing.as_ref()) {
449            let bytes = pending.row_bytes(false);
450            let row = cancelled_row(pending.relative_path, Some(pending.work));
451            observer.file_finished(FileRow::Add(&row));
452            return (row, Some(bytes));
453        }
454        let start = Instant::now();
455        let added = add_file(
456            &mut pending,
457            paths,
458            backend,
459            operation_id,
460            missing.as_ref(),
461            dry_run,
462            observer,
463        );
464        pending.work += start.elapsed();
465        let (result, bytes) = match added {
466            Ok(added) => {
467                log::info!(
468                    "Successfully added {} ({:?})",
469                    pending.relative_path.display(),
470                    added.outcome
471                );
472                // Storing content that was already there moves no bytes
473                let bytes = pending.row_bytes(added.created);
474                let result = AddResult {
475                    path: pending.relative_path,
476                    detail: AddDetail::Success {
477                        outcome: added.outcome,
478                        hash: pending.metadata.hashes.blake3,
479                        size: pending.metadata.size,
480                        stored_size: added.stored_size.unwrap_or(pending.metadata.size),
481                    },
482                    duration_ms: Some(millis(pending.work)),
483                };
484                (result, bytes)
485            }
486            Err(e) => {
487                let bytes = pending.row_bytes(false);
488                let row = error_row(pending.relative_path, &e, Some(millis(pending.work)));
489                (row, bytes)
490            }
491        };
492        observer.file_finished(FileRow::Add(&result));
493        (result, Some(bytes))
494    };
495    results.extend(pool.install(|| pending.into_par_iter().map(finish_one).collect::<Vec<_>>()));
496
497    let mut bytes = ByteTotals::default();
498    let mut results: Vec<AddResult> = results
499        .into_iter()
500        .map(|(result, row_bytes)| {
501            if let Some(row) = &row_bytes {
502                bytes.count(row.size, row.stored, row.transferred);
503            }
504            result
505        })
506        .collect();
507    results.sort_by(|a, b| a.path.cmp(&b.path));
508
509    let successful_paths: Vec<_> = results
510        .iter()
511        .filter(|r| matches!(r.detail, AddDetail::Success { .. }))
512        .map(|r| r.path.clone())
513        .collect();
514    if !dry_run && !successful_paths.is_empty() {
515        // Successfully added bytes are what dvs last placed at these paths, so a
516        // later `get` may replace them without asking (see files/get.rs).
517        for r in &results {
518            if let AddDetail::Success { hash, size, .. } = &r.detail {
519                cache::record_baseline(cache.as_ref(), &r.path.to_string_lossy(), hash, *size);
520            }
521        }
522        if let Err(e) = add_to_gitignore(paths.repo_root(), &successful_paths) {
523            log::warn!("Failed to update .gitignore: {e}");
524        }
525    }
526
527    Ok(AddReport::new(results, bytes, ctx.is_cancelled()))
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::operation::{CancelToken, TransferObserver};
534    use crate::progress::FileProgress;
535    use crate::testutil::{Recorder, create_file, create_temp_git_repo, init_dvs_repo};
536    use fs_err as fs;
537    use std::path::Path;
538    use std::sync::Arc;
539    use std::sync::atomic::{AtomicUsize, Ordering};
540
541    #[test]
542    fn row_errors_carry_a_code() {
543        let backend = anyhow::Error::from(crate::Error::new(ErrorCode::AccessDenied, "no"));
544        let io = anyhow::Error::from(std::io::Error::other("disk"));
545        for (error, expected) in [
546            (backend, ErrorCode::AccessDenied),
547            (io, ErrorCode::IoError),
548            (anyhow::anyhow!("other"), ErrorCode::OperationFailed),
549        ] {
550            assert_eq!(add_error_code(&error), expected);
551        }
552    }
553
554    /// A local-storage project: its temp dir guard, root, config and paths.
555    fn project() -> (tempfile::TempDir, PathBuf, crate::config::Config, DvsPaths) {
556        let (tmp, root) = create_temp_git_repo();
557        let (config, _dvs_dir) = init_dvs_repo(&root);
558        let paths = DvsPaths::new(root.clone(), root.clone(), config.metadata_folder_name());
559        (tmp, root, config, paths.unwrap())
560    }
561
562    #[test]
563    fn add_files_aborts_batch_when_any_file_missing() {
564        let (_tmp, root, config, paths) = project();
565        let backend = config.backend();
566
567        // One valid file alongside a missing one.
568        create_file(&root, "a.txt", b"a");
569
570        let err = add_files(
571            vec!["a.txt".into(), "missing.csv".into()],
572            &paths,
573            backend,
574            None,
575            Compression::Zstd,
576            false,
577            &OperationContext::new(),
578        )
579        .unwrap_err()
580        .to_string();
581        assert!(err.contains("missing.csv"), "unexpected error: {err}");
582
583        // No partial success: the valid file must not have been added.
584        assert!(
585            !paths.metadata_path(Path::new("a.txt")).exists(),
586            "no files should be added when one is missing"
587        );
588    }
589
590    #[test]
591    fn add_files_aborts_when_path_outside_or_directory() {
592        let (_tmp, root, config, paths) = project();
593        let backend = config.backend();
594
595        // Valid file
596        create_file(&root, "a.txt", b"a");
597
598        // Outside-project file: a sibling of the repo, outside its root.
599        let outside_file = root.parent().unwrap().join("outside.txt");
600        fs::write(&outside_file, b"outside").unwrap();
601        let outside_relative = PathBuf::from("..").join("outside.txt");
602
603        // Directory inside the repo
604        fs::create_dir(root.join("subdir")).unwrap();
605
606        // An outside-project path or a directory is invalid up front, so the
607        // whole batch is refused rather than partially added.
608        let err = add_files(
609            vec!["a.txt".into(), outside_relative, "subdir".into()],
610            &paths,
611            backend,
612            None,
613            Compression::Zstd,
614            false,
615            &OperationContext::new(),
616        )
617        .unwrap_err();
618        assert_eq!(backend_error_code(&err), Some(ErrorCode::InvalidArguments));
619        let err = err.to_string();
620        assert!(err.contains("outside project"), "unexpected error: {err}");
621        assert!(err.contains("directory"), "unexpected error: {err}");
622
623        // No partial success: the valid file must not have been added.
624        assert!(
625            !paths.metadata_path(Path::new("a.txt")).exists(),
626            "no files should be added when any path is invalid"
627        );
628    }
629
630    #[test]
631    fn add_files_invokes_on_done_for_each_file() {
632        let (_tmp, root, config, paths) = project();
633        let backend = config.backend();
634
635        create_file(&root, "a.txt", b"a");
636        create_file(&root, "b.txt", b"b");
637        create_file(&root, "c.txt", b"c");
638
639        let done_calls = Arc::new(AtomicUsize::new(0));
640        let success_calls = Arc::new(AtomicUsize::new(0));
641        let on_file_start = {
642            let done_calls = Arc::clone(&done_calls);
643            let success_calls = Arc::clone(&success_calls);
644            move |_path: &Path, _size: u64| {
645                let done_calls = Arc::clone(&done_calls);
646                let success_calls = Arc::clone(&success_calls);
647                FileProgress {
648                    on_bytes: Box::new(|_| {}),
649                    on_done: Box::new(move |success| {
650                        done_calls.fetch_add(1, Ordering::SeqCst);
651                        if success {
652                            success_calls.fetch_add(1, Ordering::SeqCst);
653                        }
654                    }),
655                }
656            }
657        };
658
659        let results = add_files(
660            vec!["a.txt".into(), "b.txt".into(), "c.txt".into()],
661            &paths,
662            backend,
663            None,
664            Compression::Zstd,
665            false,
666            &OperationContext::new().with_observer(Arc::new(TransferObserver(on_file_start))),
667        )
668        .unwrap()
669        .files;
670
671        assert_eq!(results.len(), 3);
672        assert!(
673            results
674                .iter()
675                .all(|r| matches!(r.detail, AddDetail::Success { .. }))
676        );
677        assert_eq!(done_calls.load(Ordering::SeqCst), 3);
678        assert_eq!(success_calls.load(Ordering::SeqCst), 3);
679    }
680
681    fn add(
682        names: &[&str],
683        paths: &DvsPaths,
684        backend: &Backend,
685        ctx: &OperationContext,
686    ) -> AddReport {
687        add_files(
688            names.iter().map(PathBuf::from).collect(),
689            paths,
690            backend,
691            None,
692            Compression::Zstd,
693            false,
694            ctx,
695        )
696        .unwrap()
697    }
698
699    fn content_hashes(content: &[u8]) -> crate::Hashes {
700        let tmp = tempfile::NamedTempFile::new().unwrap();
701        fs::write(tmp.path(), content).unwrap();
702        crate::Hashes::compute_from_path(tmp.path(), &[]).unwrap().0
703    }
704
705    #[test]
706    fn cancel_mid_add_finishes_in_flight_files_and_records_nothing_else() {
707        let (_tmp, root, config, paths) = project();
708        let backend = config.backend();
709        // More files than the pool can have in flight, so some never start
710        let names: Vec<String> = (0..48).map(|i| format!("f{i:02}.txt")).collect();
711        for name in &names {
712            create_file(&root, name, name.as_bytes());
713        }
714        let names: Vec<&str> = names.iter().map(String::as_str).collect();
715
716        let token = CancelToken::new();
717        let recorder = Arc::new(Recorder::cancelling_on_transfer(token.clone(), 1));
718        let ctx = OperationContext::new()
719            .with_cancel(token)
720            .with_observer(recorder.clone());
721        let report = add(&names, &paths, backend, &ctx);
722
723        let seen = recorder.seen();
724        let transfers: Vec<_> = seen
725            .iter()
726            .filter_map(|s| s.strip_prefix("transfer "))
727            .map(PathBuf::from)
728            .collect();
729        let rows: HashSet<_> = seen.iter().filter(|s| s.starts_with("row ")).collect();
730        let (added, cancelled): (Vec<_>, Vec<_>) = report
731            .files
732            .iter()
733            .partition(|r| r.state() == RowState::Succeeded);
734        assert_eq!(report.outcome, RunOutcome::Cancelled);
735        assert_eq!(report.counts.cancelled, cancelled.len());
736        assert!(cancelled.iter().all(|r| r.state() == RowState::Cancelled));
737        // One row per file, reported once each
738        assert_eq!(report.files.len(), 48);
739        assert_eq!((rows.len(), recorder.rows.lock().unwrap().len()), (48, 48));
740        // Exactly the files in flight when the cancel came finished
741        assert_eq!(added.len(), transfers.len());
742        assert!(!cancelled.is_empty());
743        for row in &added {
744            assert!(transfers.contains(&row.path));
745            assert!(paths.metadata_path(&row.path).is_file());
746            let content = fs::read(paths.file_path(&row.path)).unwrap();
747            assert!(backend.exists(&content_hashes(&content)).unwrap());
748            assert!(row.duration_ms.is_some());
749        }
750        // Files that never started have neither a record nor a blob, but were
751        // hashed before the cancel came, so their hashing time is reported
752        for row in &cancelled {
753            assert!(!transfers.contains(&row.path));
754            assert!(!paths.metadata_path(&row.path).exists());
755            let content = fs::read(paths.file_path(&row.path)).unwrap();
756            assert!(!backend.exists(&content_hashes(&content)).unwrap());
757            assert!(row.duration_ms.is_some());
758        }
759        // Every file was hashed before the cancel, so every row counts in the
760        // plan's total; only what was added was transferred
761        let all_bytes: u64 = names.iter().map(|n| n.len() as u64).sum();
762        let added_bytes: u64 = added.iter().map(|r| r.path.as_os_str().len() as u64).sum();
763        assert!(seen.contains(&format!("plan 48 {all_bytes} 48 {all_bytes}")));
764        assert_eq!(report.bytes.total, all_bytes);
765        assert_eq!(report.bytes.to_transfer, all_bytes);
766        assert_eq!(report.bytes.transferred, added_bytes);
767    }
768
769    #[test]
770    fn a_run_that_fails_after_a_cancel_is_reported_cancelled() {
771        let (_tmp, _root, config, paths) = project();
772        let token = CancelToken::new();
773        token.cancel();
774        // A missing path refuses the batch
775        let report = add_files(
776            vec!["missing.csv".into()],
777            &paths,
778            config.backend(),
779            None,
780            Compression::Zstd,
781            false,
782            &OperationContext::new().with_cancel(token),
783        )
784        .unwrap();
785        assert_eq!(report.outcome, RunOutcome::Cancelled);
786        assert!(report.files.is_empty());
787    }
788
789    #[test]
790    fn after_a_cancel_only_files_the_server_already_recorded_start() {
791        let missing: HashSet<String> = ["new".to_string()].into();
792        // The manifest recorded "old" (the server has it), but not "new"
793        assert!(!cancelled_before_start(true, "old", Some(&missing)));
794        assert!(cancelled_before_start(true, "new", Some(&missing)));
795        // No manifest (local storage): every file not yet started is cancelled
796        assert!(cancelled_before_start(true, "old", None));
797        assert!(!cancelled_before_start(false, "new", Some(&missing)));
798    }
799
800    #[test]
801    fn observer_hears_the_plan_then_transfers_of_stored_files_only_then_each_row() {
802        let (_tmp, root, config, paths) = project();
803        let backend = config.backend();
804        create_file(&root, "a.txt", b"aaaa");
805        add(&["a.txt"], &paths, backend, &OperationContext::new());
806        create_file(&root, "c.txt", b"c");
807
808        let recorder = Arc::new(Recorder::default());
809        let ctx = OperationContext::new().with_observer(recorder.clone());
810        let report = add(&["a.txt", "c.txt"], &paths, backend, &ctx);
811        let seen = recorder.seen();
812        let at = |line: &str| seen.iter().position(|s| s == line).unwrap();
813        // a.txt is in sync: only c.txt is stored, so it alone is a transfer
814        let plan = at("plan 2 5 1 1");
815        let transfers: Vec<_> = seen.iter().filter(|s| s.starts_with("transfer")).collect();
816        assert_eq!(transfers, ["transfer c.txt"]);
817        assert!(plan < at("transfer c.txt") && at("transfer c.txt") < at("row c.txt"));
818        assert!(plan < at("row a.txt"));
819        assert_eq!(seen.iter().filter(|s| s.starts_with("row")).count(), 2);
820        // The library serializes each row's time
821        assert!(serde_json::to_value(&report.files[0]).unwrap()["duration_ms"].is_u64());
822    }
823
824    #[cfg(unix)]
825    #[test]
826    fn counts_include_failed_rows() {
827        use std::os::unix::fs::PermissionsExt;
828        let (_tmp, root, config, paths) = project();
829        create_file(&root, "a.txt", b"a");
830        let b = create_file(&root, "b.txt", b"b");
831        fs::set_permissions(&b, std::fs::Permissions::from_mode(0o000)).unwrap();
832        if fs::read(&b).is_ok() {
833            return; // running as root: permissions are not enforced
834        }
835
836        let report = add(
837            &["a.txt", "b.txt"],
838            &paths,
839            config.backend(),
840            &OperationContext::new(),
841        );
842        let c = report.counts;
843        assert_eq!((c.total, c.succeeded, c.failed), (2, 1, 1));
844    }
845}