aede_core/copy/mod.rs
1//! Copying a selection out of the library, keeping the tree it sits in.
2//!
3//! What this is for: filling a portable player, a card, an external drive —
4//! somewhere that is not a library and will never be scanned. The copy is a
5//! **derived artifact**, not a second catalog, and nothing here writes to the
6//! catalog or to the files it reads.
7//!
8//! Two halves, and the split is the point. [`plan`] decides everything and
9//! touches nothing: which files, where each one lands, what had to be renamed,
10//! what could not be placed at all. Only then does the caller write. A run that
11//! can say what it is about to do before doing it is a run that can be shown to
12//! the user first — which is what `--dry-run` is — and, more importantly, one
13//! whose decisions can be tested without a filesystem.
14//!
15//! The tree is kept **relative to the watched root that holds the file**, so a
16//! track at `/Volumes/Music/Ozzy/1980 Blizzard/01.flac`, scanned under
17//! `/Volumes/Music`, lands at `<destination>/Ozzy/1980 Blizzard/01.flac`. The
18//! alternative — inventing a layout from the tags — is a different feature
19//! (organising), and one this project has not decided it wants.
20
21pub mod names;
22mod output;
23mod playlists;
24pub mod transcode;
25pub use output::{
26 TemporaryOutput, files_match, validate_destination, validate_filesystem_names,
27 validate_new_publication, validate_source,
28};
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::path::{Path, PathBuf};
32
33use crate::model::{Catalog, Id};
34use crate::text;
35
36/// Which files beside the audio travel with it.
37///
38/// The ladder exists because "images" is the wrong question. A rip folder holds
39/// the cover *and* the spectrograms *and* the scans of the booklet, all of them
40/// PNG or JPEG, and a player wants exactly one of the three. The catalog
41/// already knows which: the scan picked a cover by rank and stored it on the
42/// release. [`Extras::Cover`] is therefore an exact answer where
43/// [`Extras::Images`] can only be a heuristic.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum Extras {
46 /// Audio only. Cover art embedded in the tags still travels: it is inside
47 /// the file.
48 None,
49 /// The one cover the catalog identified for the release.
50 #[default]
51 Cover,
52 /// Every image in the folder, spectrograms and booklet scans included.
53 Images,
54 /// Everything sitting beside the audio: logs, cue sheets, reports.
55 All,
56}
57
58impl Extras {
59 /// The keyword as it is typed, or `None` for a word that names none.
60 pub fn parse(word: &str) -> Option<Extras> {
61 match text::normalize(word).as_str() {
62 "none" => Some(Extras::None),
63 "cover" | "covers" => Some(Extras::Cover),
64 "image" | "images" => Some(Extras::Images),
65 "all" => Some(Extras::All),
66 _ => None,
67 }
68 }
69
70 /// Every spelling accepted, for a message that offers what it refuses.
71 pub const NAMES: &'static str = "none, cover, images, all";
72}
73
74/// Extensions counted as an image by [`Extras::Images`].
75const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff"];
76
77/// What a file is doing in the plan, which is what lets a report say "12
78/// albums and their covers" rather than a single undifferentiated count.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
80pub enum ItemKind {
81 /// A track that was selected.
82 Audio,
83 /// The cover art of a release holding a selected track.
84 Cover,
85 /// Anything else asked for by [`Extras::Images`] or [`Extras::All`].
86 Other,
87}
88
89/// One file to write, and where.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Item {
92 /// Where it is read from.
93 pub source: PathBuf,
94 /// Where it goes, relative to the destination folder.
95 ///
96 /// Relative rather than absolute so the plan can be compared, printed and
97 /// tested without knowing which drive it is bound for.
98 pub relative: PathBuf,
99 /// Bytes, as the catalog last read them — or an **estimate** when this
100 /// item is to be converted, since what an encoder produces is not known
101 /// until it has produced it.
102 pub size: u64,
103 /// Why it is here.
104 pub kind: ItemKind,
105 /// What to encode it into, or `None` to copy the bytes as they are.
106 pub convert: Option<transcode::Target>,
107 /// UTF-8 derivative text to write instead of copying source bytes.
108 /// Set only when destination playlists have explicitly been requested.
109 pub contents: Option<String>,
110}
111
112/// A name the destination could not have taken as it stood.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Renamed {
115 /// The path as it is in the library.
116 pub from: PathBuf,
117 /// The path as it will be written.
118 pub to: PathBuf,
119}
120
121/// Everything a copy would do, decided before anything is written.
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
123pub struct Plan {
124 /// The files to write, in a stable order.
125 pub items: Vec<Item>,
126 /// Every component the destination filesystem forced a change to.
127 pub renamed: Vec<Renamed>,
128 /// Selected files sitting under no watched root, which therefore have no
129 /// tree to keep. Reported rather than dropped, and rather than invented a
130 /// place for.
131 pub rootless: Vec<PathBuf>,
132 /// Sources or requested companion folders that cannot be read or converted
133 /// as requested. The caller must refuse these before writing.
134 pub rejected: Vec<Failed>,
135 /// Audio files being encoded whose embedded cover cannot follow them into
136 /// the target format, because ffmpeg refuses to mux a picture stream into
137 /// it — see [`transcode::Target::keeps_embedded_art`]. Counted rather
138 /// than named: the loss is the same whichever file it happens to.
139 pub covers_the_target_cannot_hold: usize,
140 /// Audio files being encoded into a target whose tag format has no room
141 /// for one of the tags they carry — see
142 /// [`transcode::Target::tags_it_would_drop`]. In practice this is `wav`
143 /// and its fixed legacy vocabulary; every other target takes an
144 /// arbitrary key and never adds to this count.
145 pub tags_the_target_cannot_hold: usize,
146}
147
148impl Plan {
149 /// Bytes the copy would write.
150 pub fn total_bytes(&self) -> u64 {
151 self.items
152 .iter()
153 .fold(0, |total, item| total.saturating_add(item.size))
154 }
155
156 /// How many files of each kind, for a report that says what it is copying.
157 pub fn counts(&self) -> BTreeMap<ItemKind, usize> {
158 let mut out = BTreeMap::new();
159 for item in &self.items {
160 *out.entry(item.kind).or_insert(0) += 1;
161 }
162 out
163 }
164}
165
166/// Everything the caller chose, gathered so that a plan reads as one decision
167/// rather than as four positional booleans nobody can tell apart at the call
168/// site.
169#[derive(Debug, Clone, Copy, Default)]
170pub struct Recipe {
171 /// What travels beside the audio.
172 pub extras: Extras,
173 /// Whether the destination refuses the punctuation a music library is full
174 /// of — see [`names::restricts_names`], which answers it by asking the
175 /// volume rather than by guessing.
176 pub restrict_names: bool,
177 /// What to encode the audio into, or `None` to copy it unchanged.
178 pub convert: Option<transcode::Target>,
179 /// How hard the encoder should try.
180 pub quality: Option<transcode::Quality>,
181}
182
183/// Whether a file should be encoded on the way out, and what into.
184///
185/// **One rule, and it falls out of what conversion is for.** A file is encoded
186/// only when it is lossless *and* not already in the target format. Everything
187/// else is copied as it stands, which covers three cases that would otherwise
188/// each need their own argument:
189///
190/// - it is already an MP3 and MP3 was asked for — re-encoding would lose
191/// quality to produce the same thing;
192/// - it is an MP3 and Opus was asked for — a second lossy pass over a first one
193/// is audible, and the file is already small, which was the point;
194/// - it is an MP3 and FLAC was asked for — the result would be *larger* than
195/// the source and no better: lossless in name, lossy in substance, which is
196/// the one thing nobody rips on purpose. Producing it deliberately would be
197/// absurd.
198///
199/// So a mixed library converted for a phone comes out with its lossless half
200/// encoded and its lossy half untouched, which is what somebody filling a phone
201/// wants and never has to ask for.
202fn conversion_for(
203 file: &crate::model::AudioFile,
204 target: Option<transcode::Target>,
205) -> Option<transcode::Target> {
206 let target = target?;
207 if !file.properties.lossless {
208 return None;
209 }
210 // A container extension does not identify its codec: lossless ALAC in
211 // M4A must still be encoded when AAC is requested. Container aliases such
212 // as .wave already hold WAV and can be copied without re-encoding.
213 let already = match target {
214 transcode::Target::Flac => {
215 file.properties.codec.eq_ignore_ascii_case("flac")
216 && (file.properties.container.eq_ignore_ascii_case("flac")
217 || (file.properties.container.is_empty()
218 && Path::new(&file.path)
219 .extension()
220 .is_some_and(|extension| extension.eq_ignore_ascii_case("flac"))))
221 }
222 transcode::Target::Wav => file.properties.container.eq_ignore_ascii_case("wav"),
223 transcode::Target::Mp3 => file.properties.codec.eq_ignore_ascii_case("mp3"),
224 transcode::Target::Opus => file.properties.codec.eq_ignore_ascii_case("opus"),
225 transcode::Target::Aac => file.properties.codec.eq_ignore_ascii_case("aac"),
226 transcode::Target::Vorbis => file.properties.codec.eq_ignore_ascii_case("vorbis"),
227 };
228 (!already).then_some(target)
229}
230
231/// Project the output of one already-placed portable source, as used by serial desktop exports.
232/// This reuses codec-aware conversion and single-file placement without touching the filesystem.
233/// The caller must reject collisions between projected outputs from different staged sources.
234pub fn staged_output_path(
235 file: &crate::model::AudioFile,
236 relative: &str,
237 target: Option<transcode::Target>,
238) -> PathBuf {
239 let renamed = match conversion_for(file, target) {
240 Some(target) => with_extension(relative, target.extension()),
241 None => relative.to_owned(),
242 };
243 Placement::default().place(&renamed, relative, true)
244}
245
246/// Works out what copying these tracks would mean. Writes nothing, and reads
247/// the disk only to list what sits beside the audio: every other answer comes
248/// from the catalog.
249pub fn plan(catalog: &Catalog, tracks: &[Id], recipe: &Recipe) -> Plan {
250 let (extras, restrict_names) = (recipe.extras, recipe.restrict_names);
251 let mut out = Plan::default();
252 // Two tracks can share a file — the same path selected twice through two
253 // routes — and a folder's extras are gathered once however many of its
254 // tracks were picked.
255 let mut sources: BTreeSet<&str> = BTreeSet::new();
256 let mut folders: BTreeSet<&str> = BTreeSet::new();
257 let mut releases: BTreeSet<Id> = BTreeSet::new();
258
259 for &id in tracks {
260 let Some(track) = catalog.track(id) else {
261 continue;
262 };
263 let Some(file) = catalog.file(track.file_id) else {
264 continue;
265 };
266 if sources.insert(file.path.as_str()) {
267 folders.insert(text::folder(&file.path));
268 }
269 if let Some(release_id) = track.release_id {
270 releases.insert(release_id);
271 }
272 }
273
274 let mut wanted: Vec<(String, ItemKind)> = sources
275 .iter()
276 .map(|path| ((*path).to_string(), ItemKind::Audio))
277 .collect();
278
279 // The cover the catalog settled on, which is the whole reason `Cover` can
280 // be exact where an extension filter cannot.
281 if extras == Extras::Cover {
282 for release_id in &releases {
283 if let Some(cover) = catalog
284 .release(*release_id)
285 .and_then(|r| r.cover_path.as_deref())
286 {
287 wanted.push((cover.to_string(), ItemKind::Cover));
288 }
289 }
290 }
291 if matches!(extras, Extras::Images | Extras::All) {
292 for folder in &folders {
293 match beside(folder, extras) {
294 Ok(paths) => wanted.extend(paths.into_iter().map(|path| (path, ItemKind::Other))),
295 Err(failure) => out.rejected.push(failure),
296 }
297 }
298 }
299
300 // Sorted and deduplicated before any path is decided, so that the same
301 // catalog and the same selection produce the same plan every time — the
302 // renaming below depends on the order names are met in. `Audio` sorts
303 // before `Cover` and `Other`, so a file wanted for two reasons is kept as
304 // the more specific one.
305 wanted.sort();
306 wanted.dedup_by(|a, b| a.0 == b.0);
307
308 let files: BTreeMap<&str, &crate::model::AudioFile> = catalog
309 .files
310 .iter()
311 .map(|file| (file.path.as_str(), file))
312 .collect();
313 let mut placement = Placement::default();
314 for (path, kind) in wanted {
315 let Some(relative) = under_a_root(catalog, &path) else {
316 out.rootless.push(PathBuf::from(path));
317 continue;
318 };
319 let file = files.get(path.as_str()).copied();
320 // Only the audio is ever encoded: a cover is a cover on any device.
321 let convert = match kind {
322 ItemKind::Audio => file.and_then(|f| conversion_for(f, recipe.convert)),
323 _ => None,
324 };
325 // Said once, before anything is written, rather than left for a
326 // player to discover: an encode that quietly drops a cover or a tag
327 // is a wrong answer standing in for a missing one.
328 if let (Some(target), Some(f)) = (convert, file) {
329 if let Err(reason) = transcode::validate_conversion(&f.properties, target) {
330 out.rejected.push(Failed {
331 source: PathBuf::from(&path),
332 reason,
333 });
334 continue;
335 }
336 if !target.keeps_embedded_art() && f.has_embedded_art {
337 out.covers_the_target_cannot_hold += 1;
338 }
339 if !target.tags_it_would_drop(&f.tags).is_empty() {
340 out.tags_the_target_cannot_hold += 1;
341 }
342 }
343 // The extension changes **before** the name is placed, so that two
344 // sources landing on one name — `01.flac` and `01.wav` both becoming
345 // `01.mp3` — are seen as the collision they are rather than one file
346 // written over the other.
347 let renamed_relative = match convert {
348 Some(target) => with_extension(&relative, target.extension()),
349 None => relative.clone(),
350 };
351 let placed = placement.place(&renamed_relative, &path, restrict_names);
352 if placed != Path::new(&relative) {
353 out.renamed.push(Renamed {
354 from: PathBuf::from(&relative),
355 to: placed.clone(),
356 });
357 }
358 let size = match convert {
359 Some(target) => transcode::estimated_size(
360 target,
361 recipe.quality,
362 file.and_then(|f| f.properties.duration_ms).unwrap_or(0),
363 file.map(|f| f.size).unwrap_or(0),
364 ),
365 None => file
366 .map(|f| f.size)
367 .unwrap_or_else(|| std::fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0)),
368 };
369 out.items.push(Item {
370 size,
371 source: PathBuf::from(path),
372 relative: placed,
373 kind,
374 convert,
375 contents: None,
376 });
377 }
378 out
379}
380
381/// The same relative path with another extension on its last component.
382fn with_extension(relative: &str, extension: &str) -> String {
383 let mut path = PathBuf::from(relative);
384 path.set_extension(extension);
385 // Placement uses portable separators even when PathBuf changed the suffix on Windows.
386 path.components()
387 .map(|part| part.as_os_str().to_string_lossy())
388 .collect::<Vec<_>>()
389 .join("/")
390}
391
392/// How many files the plan would encode rather than copy.
393impl Plan {
394 /// Items that go through an encoder, and those that are copied as they are.
395 ///
396 /// Reported separately because the difference is the one thing a user needs
397 /// to see before a conversion starts: a library that is half MP3 already
398 /// comes out half untouched, and a count that hid that would look like the
399 /// conversion had silently skipped things.
400 pub fn converted(&self) -> usize {
401 self.items
402 .iter()
403 .filter(|item| item.convert.is_some())
404 .count()
405 }
406
407 /// `true` when any size in the plan is an encoder's output, and therefore
408 /// a guess rather than a measurement.
409 pub fn size_is_estimated(&self) -> bool {
410 self.converted() > 0
411 }
412}
413
414/// The files sitting beside the audio that this level of `extras` asks for.
415///
416/// The one place in this module that reads the disk, and it has to: the catalog
417/// holds what the scan recognised as audio, and everything asked for here is by
418/// definition what it did not. Sorted, so two runs agree.
419///
420/// An unreadable requested folder is an error: reporting a complete copy while
421/// silently omitting selected companions would not honour `extras`.
422fn beside(folder: &str, extras: Extras) -> Result<Vec<String>, Failed> {
423 let folder = Path::new(folder);
424 let mut found: Vec<String> = Vec::new();
425 let folders = add_files_of(folder, extras, &mut found)?;
426 // Aède writes what it draws or keeps beside a track into a subfolder of
427 // its own rather than into the album folder directly — `spectrograms/`
428 // today, and both `Extras::Images` and `Extras::All` are documented to
429 // reach a spectrogram, not just the two of them that happen to be
430 // sitting loose next to the audio. One level down and no further: a
431 // folder inside `spectrograms/` would not be Aède's, and guessing at its
432 // shape is not this function's business.
433 for folder in folders {
434 add_files_of(&folder, extras, &mut found)?;
435 }
436 found.sort();
437 Ok(found)
438}
439
440/// Every plain file directly inside `folder` that this level of `extras`
441/// wants, appended as an absolute path.
442///
443/// Shared between the album folder itself and each subfolder [`beside`] steps
444/// into, so a dotfile or an audio file is refused the same way wherever it is
445/// found.
446fn add_files_of(
447 folder: &Path,
448 extras: Extras,
449 found: &mut Vec<String>,
450) -> Result<Vec<PathBuf>, Failed> {
451 let entries = std::fs::read_dir(folder).map_err(|error| extra_error(folder, error))?;
452 let mut folders = Vec::new();
453 for entry in entries {
454 let entry = entry.map_err(|error| extra_error(folder, error))?;
455 let kind = entry
456 .file_type()
457 .map_err(|error| extra_error(&entry.path(), error))?;
458 if kind.is_dir() {
459 folders.push(entry.path());
460 continue;
461 }
462 if !kind.is_file() {
463 continue;
464 }
465 let path = entry.path();
466 let name = entry.file_name().to_string_lossy().to_string();
467 // A dotfile beside an album is the operating system's business —
468 // `.DS_Store`, thumbnail caches — and never something to carry onto a
469 // player.
470 if name.starts_with('.') {
471 continue;
472 }
473 // Audio is already in the plan through the catalog, with its size and
474 // its kind; picking it up again here would put it in twice under a
475 // vaguer name.
476 if crate::tags::is_audio_path(&path) {
477 continue;
478 }
479 if extras == Extras::Images && !is_image(&name) {
480 continue;
481 }
482 found.push(path.to_string_lossy().to_string());
483 }
484 folders.sort();
485 Ok(folders)
486}
487
488fn extra_error(path: &Path, error: std::io::Error) -> Failed {
489 Failed {
490 source: path.to_path_buf(),
491 reason: format!("cannot read requested companions: {error}"),
492 }
493}
494
495/// `true` when a file name ends in an extension pictures use.
496fn is_image(name: &str) -> bool {
497 name.rsplit_once('.').is_some_and(|(_, extension)| {
498 let extension = extension.to_ascii_lowercase();
499 IMAGE_EXTENSIONS.contains(&extension.as_str())
500 })
501}
502
503/// The path of a file relative to the watched root that holds it.
504///
505/// `None` when no root does, which is not a fault to hide: the file has no tree
506/// to keep, and putting it at the destination's top level would silently mix it
507/// in with the folders that do.
508fn under_a_root(catalog: &Catalog, path: &str) -> Option<String> {
509 // The longest matching root wins. Nesting one watched folder inside another
510 // is legal, and taking the shorter one would carry the intermediate
511 // folders into a destination the user asked to be rid of.
512 catalog
513 .roots
514 .iter()
515 .filter(|root| text::is_under(path, root))
516 .max_by_key(|root| root.len())
517 .and_then(|root| text::relative_under(path, root).filter(|rest| !rest.is_empty()))
518}
519
520/// Original directories have stable placements; each output parent has its
521/// own namespace shared by files and directories. Conservative matching keys make
522/// the plan portable without allowing a case variant to silently merge albums.
523#[derive(Default)]
524struct Placement {
525 directories: BTreeMap<String, PathBuf>,
526 names: BTreeMap<PathBuf, BTreeSet<String>>,
527}
528
529impl Placement {
530 fn place(&mut self, relative: &str, source: &str, restrict: bool) -> PathBuf {
531 let parts: Vec<&str> = relative
532 .split('/')
533 .filter(|part| !part.is_empty())
534 .collect();
535 let Some((file, folders)) = parts.split_last() else {
536 return PathBuf::new();
537 };
538 let adapt = |part: &str| {
539 if restrict {
540 names::adapt(part).unwrap_or_else(|| part.to_string())
541 } else {
542 part.to_string()
543 }
544 };
545 let mut source_root = text::folder(source);
546 for _ in folders {
547 source_root = text::folder(source_root);
548 }
549 let mut original = format!("{source_root}/");
550 let mut parent = PathBuf::new();
551 for folder in folders {
552 original.push_str(folder);
553 original.push('/');
554 parent = match self.directories.get(&original) {
555 Some(held) => held.clone(),
556 None => {
557 let name = names::make_unique_portable(
558 &adapt(folder),
559 self.names.entry(parent.clone()).or_default(),
560 );
561 let placed = parent.join(name);
562 self.directories.insert(original.clone(), placed.clone());
563 placed
564 }
565 };
566 }
567 let name = names::make_unique_portable(
568 &adapt(file),
569 self.names.entry(parent.clone()).or_default(),
570 );
571 parent.join(name)
572 }
573}
574
575/// `true` when a destination sits inside a folder the catalog watches.
576///
577/// Copying a library into itself is not a mistake to warn about afterwards: the
578/// next scan reads the copies as new files, the catalog doubles, and `doctor`
579/// reports every album as a duplicate of itself. Refused before anything is
580/// written.
581pub fn inside_a_watched_root(catalog: &Catalog, destination: &Path) -> Option<String> {
582 let destination = destination.to_string_lossy().to_string();
583 catalog
584 .roots
585 .iter()
586 .find(|root| text::is_under(&destination, root) || text::is_under(root, &destination))
587 .cloned()
588}
589
590/// Recognizes the fixed temporary pathname used by older versions.
591///
592/// New writes use [`TemporaryOutput`] with an exclusive private directory;
593/// this helper remains available for callers identifying abandoned old files.
594pub fn partial_path(destination: &Path) -> PathBuf {
595 let name = destination
596 .file_name()
597 .map(|n| n.to_string_lossy().to_string())
598 .unwrap_or_default();
599 let (stem, extension) = match name.rfind('.') {
600 Some(dot) if dot > 0 => (&name[..dot], &name[dot..]),
601 _ => (name.as_str(), ""),
602 };
603 destination.with_file_name(format!("{stem}.aede-partial{extension}"))
604}
605
606/// What became of one file the plan named.
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum Wrote {
609 /// Written, and verified if verification was asked for.
610 Copied,
611 /// Already there, the same size, and left alone — which is what makes an
612 /// interrupted run cheap to finish.
613 Skipped,
614}
615
616/// Why one file did not make it.
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct Failed {
619 /// The file that was being written.
620 pub source: PathBuf,
621 /// What went wrong, worded for the user.
622 pub reason: String,
623}
624
625/// Copies one file through isolated temporary output.
626///
627/// `verify` flushes and compares newly copied bytes using constant memory.
628/// Read-back may still come from the operating-system cache; it does not prove
629/// durable media contents. Existing same-size files are a resume heuristic and
630/// remain unchecked by this wrapper. Use [`copy_one_checked`] to check them.
631/// Call [`validate_destination`] first when the destination belongs to a plan.
632pub fn copy_one(
633 source: &Path,
634 destination: &Path,
635 expected_size: u64,
636 verify: bool,
637 replace: bool,
638) -> Result<Wrote, String> {
639 copy_one_checked(source, destination, expected_size, verify, replace, false)
640}
641
642/// Copies a file, optionally requiring matching bytes for an existing output.
643///
644/// A mismatching existing output is retained and reported as an error; explicit
645/// replacement is required to refresh it. Verification has fixed memory use.
646pub fn copy_one_checked(
647 source: &Path,
648 destination: &Path,
649 expected_size: u64,
650 verify: bool,
651 replace: bool,
652 verify_existing: bool,
653) -> Result<Wrote, String> {
654 validate_source(source)?;
655 if let (Ok(source_path), Ok(destination_path)) =
656 (source.canonicalize(), destination.canonicalize())
657 && source_path == destination_path
658 {
659 return Err(format!(
660 "{}: source and destination are the same file",
661 destination.display()
662 ));
663 }
664 if !replace && let Some(existing) = output::regular_destination(destination)? {
665 if verify_existing {
666 return match files_match(source, destination)? {
667 true => Ok(Wrote::Skipped),
668 false => Err(format!(
669 "{}: existing content does not match the source; use --replace to refresh it",
670 destination.display()
671 )),
672 };
673 }
674 if existing.len() == expected_size && expected_size > 0 {
675 return Ok(Wrote::Skipped);
676 }
677 return Err(format!(
678 "{}: existing size differs from the source; use --replace to refresh it",
679 destination.display()
680 ));
681 }
682 let temporary = TemporaryOutput::new(destination)?;
683 let written = std::fs::copy(source, temporary.path())
684 .map_err(|error| format!("{} → {}: {error}", source.display(), destination.display()))?;
685 if written != expected_size && expected_size > 0 {
686 return Err(format!(
687 "{}: {written} bytes written where {expected_size} were expected",
688 destination.display()
689 ));
690 }
691 if verify {
692 #[cfg(windows)]
693 if !std::fs::metadata(temporary.path())
694 .map_err(|error| error.to_string())?
695 .permissions()
696 .readonly()
697 {
698 std::fs::OpenOptions::new()
699 .write(true)
700 .open(temporary.path())
701 .and_then(|file| file.sync_all())
702 .map_err(|error| error.to_string())?;
703 }
704 #[cfg(not(windows))]
705 std::fs::File::open(temporary.path())
706 .and_then(|file| file.sync_all())
707 .map_err(|error| error.to_string())?;
708 if !files_match(source, temporary.path())? {
709 return Err(format!(
710 "{}: what was written back does not match what was read",
711 destination.display()
712 ));
713 }
714 }
715 if replace {
716 temporary.publish()?;
717 } else {
718 temporary.publish_new()?;
719 }
720 Ok(Wrote::Copied)
721}
722
723#[cfg(test)]
724#[path = "copy_tests.rs"]
725mod tests;