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;