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#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AddResult {
30 pub path: PathBuf,
31 #[serde(flatten)]
32 pub detail: AddDetail,
33 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#[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
110fn 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
123struct Pending {
125 relative_path: PathBuf,
126 metadata: FileMetadata,
127 stored: bool,
128 work: Duration,
130}
131
132impl Pending {
133 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 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
166fn 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 created: bool,
182}
183
184#[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 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
250pub 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 observer.planned(&OperationPlan::default());
292 return Ok(AddReport::new(
293 Vec::new(),
294 ByteTotals::default(),
295 ctx.is_cancelled(),
296 ));
297 }
298
299 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 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 let mut results: Vec<(AddResult, Option<RowBytes>)>;
335
336 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 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 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 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 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 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 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 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 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 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 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 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 create_file(&root, "a.txt", b"a");
597
598 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 fs::create_dir(root.join("subdir")).unwrap();
605
606 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 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 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 assert_eq!(report.files.len(), 48);
739 assert_eq!((rows.len(), recorder.rows.lock().unwrap().len()), (48, 48));
740 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 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 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 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 assert!(!cancelled_before_start(true, "old", Some(&missing)));
794 assert!(cancelled_before_start(true, "new", Some(&missing)));
795 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 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 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; }
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}