xvc_file/copy/
mod.rs

1//! Home for `xvc file copy` command.
2//!
3//! It contains [`CopyCLI`] to handle command line options and [`cmd_copy`] to execute the command.
4use std::path::Path;
5
6use crate::common::compare::{diff_content_digest, diff_xvc_path_metadata};
7use crate::common::gitignore::make_ignore_handler;
8use crate::common::{filter_targets_from_store, xvc_path_metadata_map_from_disk, FileTextOrBinary};
9use crate::error::Error;
10use crate::recheck::{make_recheck_handler, RecheckOperation};
11use crate::Result;
12use anyhow::anyhow;
13use clap::Parser;
14
15use clap_complete::ArgValueCompleter;
16use xvc_core::util::completer::{strum_variants_completer, xvc_path_completer};
17use xvc_core::{debug, error, XvcOutputSender};
18use xvc_core::{ContentDigest, Diff, RecheckMethod, XvcFileType, XvcMetadata, XvcPath, XvcRoot};
19use xvc_core::{HStore, R11Store, XvcEntity, XvcStore};
20
21/// CLI for `xvc file copy`.
22#[derive(Debug, Clone, PartialEq, Eq, Parser)]
23#[command(rename_all = "kebab-case", author, version)]
24pub struct CopyCLI {
25    /// How the targets should be rechecked: One of copy, symlink, hardlink, reflink.
26    ///
27    /// Note: Reflink uses copy if the underlying file system doesn't support it.
28    #[arg(long, alias = "as", add = ArgValueCompleter::new(strum_variants_completer::<RecheckMethod>) )]
29    pub recheck_method: Option<RecheckMethod>,
30
31    /// Force even if target exists.
32    #[arg(long)]
33    pub force: bool,
34
35    /// Do not recheck the destination files
36    /// This is useful when you want to copy only records, without updating the
37    /// workspace.
38    #[arg(long)]
39    pub no_recheck: bool,
40
41    /// When copying multiple files, by default whole path is copied to the destination. This
42    /// option sets the destination to be created with the file name only.
43    #[arg(long)]
44    pub name_only: bool,
45
46    /// Source file, glob or directory within the workspace.
47    ///
48    /// If the source ends with a slash, it's considered a directory and all
49    /// files in that directory are copied.
50    ///
51    /// If the number of source files is more than one, the destination must be a directory.
52    #[arg(add = ArgValueCompleter::new(xvc_path_completer))]
53    pub source: String,
54
55    /// Location we copy file(s) to within the workspace.
56    ///
57    /// If the target ends with a slash, it's considered a directory and
58    /// created if it doesn't exist.
59    ///
60    /// If the number of source files is more than one, the destination must be a directory.
61    /// TODO: Add a tracked directory completer
62    /// we can have a file or a directory that we track and not available or we don't track and
63    /// available. It's similar situation to xvc_path_completer but we also need to check the local
64    /// paths.
65    #[arg()]
66    pub destination: String,
67}
68
69pub(crate) fn get_source_path_metadata(
70    output_snd: &XvcOutputSender,
71    xvc_root: &XvcRoot,
72    stored_xvc_path_store: &XvcStore<XvcPath>,
73    stored_xvc_metadata_store: &XvcStore<XvcMetadata>,
74    source: &str,
75    destination: &str,
76) -> Result<(HStore<XvcPath>, HStore<XvcMetadata>)> {
77    let source_targets = if source.ends_with('/') {
78        let mut source = source.to_string();
79        source.push('*');
80        vec![source]
81    } else {
82        vec![source.to_string()]
83    };
84
85    let current_dir = xvc_root.config().current_dir()?;
86    let all_sources = filter_targets_from_store(
87        output_snd,
88        xvc_root,
89        stored_xvc_path_store,
90        current_dir,
91        &Some(source_targets),
92    )?;
93    let source_metadata = stored_xvc_metadata_store.subset(all_sources.keys().copied())?;
94    let source_metadata_files = source_metadata.filter(|_xe, md| md.is_file()).cloned();
95
96    if source_metadata_files.len() > 1 && !destination.ends_with('/') {
97        return Err(anyhow!("Target must be a directory if multiple sources are given").into());
98    }
99
100    let source_xvc_path_files = all_sources.subset(source_metadata_files.keys().copied())?;
101
102    Ok((source_xvc_path_files, source_metadata_files))
103}
104
105pub(crate) fn check_if_destination_is_a_directory(
106    dir_path: &XvcPath,
107    stored_xvc_path_store: &XvcStore<XvcPath>,
108    stored_metadata_store: &XvcStore<XvcMetadata>,
109) -> Result<()> {
110    let current_dir_entity = stored_xvc_path_store.entities_for(dir_path).map(|v| v[0]);
111
112    let current_dir_metadata = current_dir_entity.and_then(|e| stored_metadata_store.get(&e));
113
114    if let Some(current_dir_metadata) = current_dir_metadata {
115        if !current_dir_metadata.is_dir() {
116            return Err(anyhow!(
117                        "Destination is not recorded as a directory. Please move or delete the destination first."
118                    )
119                    .into());
120        }
121    }
122    Ok(())
123}
124
125pub(crate) fn check_if_sources_have_changed(
126    output_snd: &XvcOutputSender,
127    xvc_root: &XvcRoot,
128    stored_xvc_path_store: &XvcStore<XvcPath>,
129    stored_metadata_store: &XvcStore<XvcMetadata>,
130    source_xvc_paths: &HStore<XvcPath>,
131    _source_metadata: &HStore<XvcMetadata>,
132) -> Result<()> {
133    // We don't parallelize the diff operation because we abort all operations if there is a single changed file.
134    let pmm = xvc_path_metadata_map_from_disk(xvc_root, source_xvc_paths);
135    let xvc_path_metadata_diff =
136        diff_xvc_path_metadata(xvc_root, stored_xvc_path_store, stored_metadata_store, &pmm);
137    let stored_content_digest_store = xvc_root.load_store::<ContentDigest>()?;
138    let stored_text_or_binary_store = xvc_root.load_store::<FileTextOrBinary>()?;
139    let content_digest_diff = diff_content_digest(
140        output_snd,
141        xvc_root,
142        stored_xvc_path_store,
143        stored_metadata_store,
144        &stored_content_digest_store,
145        &stored_text_or_binary_store,
146        &xvc_path_metadata_diff.0,
147        &xvc_path_metadata_diff.1,
148        None,
149        None,
150        true,
151    );
152    let changed_path_entities = content_digest_diff
153        .iter()
154        .filter_map(|(e, diff)| {
155            // We only care about changed files, not missing ones
156            if matches!(diff, Diff::<ContentDigest>::Different { .. }) {
157                Some((*e, stored_xvc_path_store.get(e).cloned().unwrap()))
158            } else {
159                None
160            }
161        })
162        .collect::<HStore<XvcPath>>();
163
164    if !changed_path_entities.is_empty() {
165        Err(Error::SourcesHaveChanged {
166            message:
167                "Sources have changed, please carry-in or recheck following files before copying"
168                    .into(),
169            files: changed_path_entities
170                .values()
171                .map(|p| p.to_string())
172                .collect::<Vec<String>>()
173                .join("\n"),
174        })
175    } else {
176        Ok(())
177    }
178}
179
180/// Build a store to match destination path entities with source path entities.
181/// It creates the destination entities with [`XvcRoot::new_entity()`] if they are not already found.
182/// `stored_xvc_path_store` and `stored_xvc_metadata_store` are loaded with `XvcRoot::load_store`, and
183/// `source_xvc_paths` and `source_xvc_metadata` are the results of [`targets_from_disk`].
184#[allow(clippy::too_many_arguments)]
185pub fn get_copy_source_dest_store(
186    output_snd: &XvcOutputSender,
187    xvc_root: &XvcRoot,
188    stored_xvc_path_store: &XvcStore<XvcPath>,
189    stored_xvc_metadata_store: &XvcStore<XvcMetadata>,
190    source_xvc_paths: &HStore<XvcPath>,
191    source_xvc_metadata: &HStore<XvcMetadata>,
192    destination: &str,
193    name_only: bool,
194    force: bool,
195) -> Result<HStore<(XvcEntity, XvcPath)>> {
196    // Create targets in the store
197    // If destination is a directory, check if exists and create if not.
198    // If destination is a file, check if exists and return error if it does and
199    // force is not set.
200    let source_dest_store = if destination.ends_with('/') {
201        let dir_path = XvcPath::new(
202            xvc_root,
203            xvc_root,
204            Path::new(destination.strip_suffix('/').unwrap()),
205        )?;
206
207        check_if_destination_is_a_directory(
208            &dir_path,
209            stored_xvc_path_store,
210            stored_xvc_metadata_store,
211        )?;
212
213        check_if_sources_have_changed(
214            output_snd,
215            xvc_root,
216            stored_xvc_path_store,
217            stored_xvc_metadata_store,
218            source_xvc_paths,
219            source_xvc_metadata,
220        )?;
221
222        let mut source_dest_store = HStore::new();
223
224        for (source_xe, source_path) in source_xvc_paths.iter() {
225            let dest_path = if name_only {
226                dir_path.join_file_name(source_path)?
227            } else {
228                dir_path.join(source_path)?
229            };
230
231            match stored_xvc_path_store.entities_for(&dest_path) {
232                Some(v) => {
233                    if !force {
234                        error!(
235                            output_snd,
236                            "Destination file {} already exists. Use --force to overwrite.",
237                            dest_path
238                        );
239                        continue;
240                    } else {
241                        source_dest_store.insert(*source_xe, (v[0], dest_path));
242                    }
243                }
244                None => {
245                    source_dest_store.insert(*source_xe, (xvc_root.new_entity(), dest_path));
246                }
247            }
248        }
249        source_dest_store
250    } else {
251        // Destination doesn't end with '/'
252        if source_xvc_paths.len() > 1 {
253            return Err(
254                anyhow!("Destination must be a directory if multiple sources are given").into(),
255            );
256        }
257
258        check_if_sources_have_changed(
259            output_snd,
260            xvc_root,
261            stored_xvc_path_store,
262            stored_xvc_metadata_store,
263            source_xvc_paths,
264            source_xvc_metadata,
265        )?;
266
267        let current_dir = xvc_root.config().current_dir()?;
268        let source_xe = source_xvc_paths.keys().next().unwrap();
269
270        let mut source_dest_store = HStore::<(XvcEntity, XvcPath)>::with_capacity(1);
271        let dest_path = XvcPath::new(xvc_root, current_dir, Path::new(destination))?;
272
273        match stored_xvc_path_store.entities_for(&dest_path) {
274            Some(dest_xe) => {
275                if !force {
276                    return Err(anyhow!(
277                        "Destination file {} already exists. Use --force to overwrite.",
278                        dest_path
279                    )
280                    .into());
281                } else {
282                    source_dest_store.insert(*source_xe, (dest_xe[0], dest_path));
283                }
284            }
285            None => {
286                source_dest_store.insert(*source_xe, (xvc_root.new_entity(), dest_path));
287            }
288        }
289        source_dest_store
290    };
291
292    Ok(source_dest_store)
293}
294
295pub(crate) fn recheck_destination(
296    output_snd: &XvcOutputSender,
297    xvc_root: &XvcRoot,
298    destination_entities: &[XvcEntity],
299) -> Result<()> {
300    let (ignore_writer, ignore_thread) = make_ignore_handler(output_snd, xvc_root)?;
301    let (recheck_handler, recheck_thread) =
302        make_recheck_handler(output_snd, xvc_root, &ignore_writer)?;
303    // We reload to get the latest paths
304    // Interleaving might prevent this.
305    let stored_xvc_path_store = xvc_root.load_store::<XvcPath>()?;
306    let mut recheck_paths = stored_xvc_path_store.subset(destination_entities.iter().copied())?;
307    let stored_content_digests = xvc_root.load_store::<ContentDigest>()?;
308    let stored_recheck_methods = xvc_root.load_store::<RecheckMethod>()?;
309
310    recheck_paths.drain().for_each(|(xe, xvc_path)| {
311        let content_digest = stored_content_digests.get(&xe).unwrap();
312        let recheck_method = stored_recheck_methods.get(&xe).unwrap();
313        recheck_handler
314            .send(Some(RecheckOperation::Recheck {
315                xvc_path,
316                content_digest: *content_digest,
317                recheck_method: *recheck_method,
318            }))
319            .unwrap();
320    });
321
322    // Send None to signal end of operations and break the loops.
323    recheck_handler.send(None).unwrap();
324    recheck_thread.join().unwrap();
325    ignore_writer.send(None).unwrap();
326    ignore_thread.join().unwrap();
327
328    Ok(())
329}
330
331/// Entry point for `xvc file copy` command.
332/// Copies a file (and its records) to a new location in the repository
333pub fn cmd_copy(output_snd: &XvcOutputSender, xvc_root: &XvcRoot, opts: CopyCLI) -> Result<()> {
334    // Get all files to copy
335
336    let stored_metadata_store = xvc_root.load_store::<XvcMetadata>()?;
337    let stored_xvc_path_store = xvc_root.load_store::<XvcPath>()?;
338    let (source_xvc_paths, source_metadata) = get_source_path_metadata(
339        output_snd,
340        xvc_root,
341        &stored_xvc_path_store,
342        &stored_metadata_store,
343        &opts.source,
344        &opts.destination,
345    )?;
346
347    let source_dest_store = get_copy_source_dest_store(
348        output_snd,
349        xvc_root,
350        &stored_xvc_path_store,
351        &stored_metadata_store,
352        &source_xvc_paths,
353        &source_metadata,
354        &opts.destination,
355        opts.name_only,
356        opts.force,
357    )?;
358
359    xvc_root.with_r11store_mut(|store: &mut R11Store<XvcPath, XvcMetadata>| {
360        for (source_xe, (dest_xe, dest_path)) in source_dest_store.iter() {
361            let source_md = stored_metadata_store.get(source_xe).unwrap();
362            store.left.insert(*dest_xe, dest_path.clone());
363            // If we recheck, we'll update the metadata with the actual
364            // file metadata below.
365            store.right.insert(*dest_xe, *source_md);
366
367            // Create destination parent directory records if they don't exist
368            for parent in dest_path.parents() {
369                let parent_entities = store.left.entities_for(&parent);
370                if parent_entities.is_none() || parent_entities.unwrap().is_empty() {
371                    let parent_entity = xvc_root.new_entity();
372                    store.left.insert(parent_entity, parent.clone());
373                    store.right.insert(
374                        parent_entity,
375                        XvcMetadata {
376                            file_type: XvcFileType::Directory,
377                            ..Default::default()
378                        },
379                    );
380                }
381            }
382        }
383
384        // Copy XvcDigest to destination
385
386        xvc_root.with_store_mut(|content_digest_store: &mut XvcStore<ContentDigest>| {
387            for (source_xe, (dest_xe, _)) in source_dest_store.iter() {
388                let cd = content_digest_store.get(source_xe);
389                match cd {
390                    Some(cd) => {
391                        content_digest_store.insert(*dest_xe, *cd);
392                    }
393                    None => {
394                        debug!(output_snd, "No content digest found for {}", source_xe);
395                    }
396                }
397            }
398            Ok(())
399        })?;
400
401        xvc_root.with_store_mut(|text_or_binary_store: &mut XvcStore<FileTextOrBinary>| {
402            for (source_xe, (dest_xe, _)) in source_dest_store.iter() {
403                let tob = text_or_binary_store.get(source_xe);
404                match tob {
405                    Some(tob) => {
406                        text_or_binary_store.insert(*dest_xe, *tob);
407                    }
408                    None => {
409                        debug!(output_snd, "No text or binary found for {}", source_xe);
410                    }
411                }
412            }
413            Ok(())
414        })?;
415
416        xvc_root.with_store_mut(|recheck_method_store: &mut XvcStore<RecheckMethod>| {
417            for (source_xe, (dest_xe, _)) in source_dest_store.iter() {
418                if let Some(recheck_method) = opts.recheck_method {
419                    recheck_method_store.insert(*dest_xe, recheck_method);
420                } else {
421                    let source_recheck_method = recheck_method_store.get(source_xe).unwrap();
422                    recheck_method_store.insert(*dest_xe, *source_recheck_method);
423                }
424            }
425            Ok(())
426        })?;
427
428        Ok(())
429    })?;
430
431    // Recheck destination files
432    //
433    // TODO: We can interleave this operation with the copy operation above
434    // to speed things up. This looks premature optimization now.
435
436    if !opts.no_recheck {
437        recheck_destination(
438            output_snd,
439            xvc_root,
440            source_dest_store
441                .iter()
442                .map(|(_, (dest_xe, _))| *dest_xe)
443                .collect::<Vec<XvcEntity>>()
444                .as_slice(),
445        )?;
446    }
447
448    Ok(())
449}