xvc_file/remove/
mod.rs

1//! The home of `xvc file remove` command.
2//!
3//! [`RemoveCLI`] defines the options of the command, and [`cmd_remove`] is the entry point.
4use std::collections::{HashMap, HashSet};
5
6use crate::common::{cache_paths_for_xvc_paths, filter_targets_from_store};
7use crate::Result;
8
9use clap::Parser;
10use clap_complete::ArgValueCompleter;
11use itertools::Itertools;
12
13use xvc_core::types::xvcdigest::DIGEST_LENGTH;
14use xvc_core::util::completer::xvc_path_completer;
15use xvc_core::{XvcCachePath, XvcRoot};
16use xvc_core::XvcEntity;
17use xvc_core::{output, uwr, warn, XvcOutputSender};
18use xvc_storage::storage::{get_storage_record, storage_identifier_completer};
19use xvc_storage::{StorageIdentifier, XvcStorageOperations};
20
21/// Remove files from Xvc cache or storage
22#[derive(Debug, Clone, PartialEq, Eq, Parser)]
23#[command(rename_all = "kebab-case", author, version)]
24pub struct RemoveCLI {
25    /// Remove files from cache
26    #[arg(long, required_unless_present = "from_storage")]
27    from_cache: bool,
28
29    /// Remove files from storage
30    #[arg(long, required_unless_present = "from_cache", add = ArgValueCompleter::new(storage_identifier_completer))]
31    from_storage: Option<StorageIdentifier>,
32
33    /// Remove all versions of the file
34    #[arg(long)]
35    all_versions: bool,
36
37    /// Remove only the specified version of the file
38    ///
39    /// Versions are specified with the content hash 123-456-789abcd.
40    /// Dashes are optional.
41    /// Prefix must be unique. If the prefix is not unique, the command will fail.
42    #[arg(long, conflicts_with = "all_versions")]
43    only_version: Option<String>,
44
45    /// Remove file versions even if they are also pointed by other targets (via deduplication)
46    #[arg(long)]
47    force: bool,
48
49    /// Files/directories to remove
50    #[arg(add = ArgValueCompleter::new(xvc_path_completer))]
51    targets: Vec<String>,
52}
53
54/// Removes a file from XVC cache or storage
55pub fn cmd_remove(output_snd: &XvcOutputSender, xvc_root: &XvcRoot, opts: RemoveCLI) -> Result<()> {
56    if !opts.from_cache && opts.from_storage.is_none() {
57        return Err(anyhow::anyhow!(
58            "At least one of --from-cache or --from-storage must be specified"
59        )
60        .into());
61    }
62
63    if opts.all_versions && opts.only_version.is_some() {
64        return Err(
65            anyhow::anyhow!("Cannot specify both --all-versions and --only-version").into(),
66        );
67    }
68
69    let current_dir = xvc_root.config().current_dir()?;
70
71    let all_paths = xvc_root.load_store()?;
72    let all_content_digests = xvc_root.load_store()?;
73    let remove_targets = filter_targets_from_store(
74        output_snd,
75        xvc_root,
76        &all_paths,
77        current_dir,
78        &Some(opts.targets),
79    )?;
80
81    let all_cache_paths = cache_paths_for_xvc_paths(output_snd, &all_paths, &all_content_digests)?;
82
83    let cache_paths_for_targets = all_cache_paths.subset(remove_targets.keys().copied())?;
84    let candidate_paths = if opts.all_versions {
85        // Return all cache paths used by the targets
86        cache_paths_for_targets
87            .iter()
88            .flat_map(|(xe, vec_cp)| {
89                vec_cp
90                    .iter()
91                    .map(|cp| (*xe, cp.clone()))
92                    .collect::<Vec<_>>()
93            })
94            .collect::<Vec<_>>()
95    } else if let Some(version) = opts.only_version {
96        // Return only the cache paths that match the version prefix
97        let version_cmp_str = version.replace('-', "");
98        let version_cmp = |v: &&XvcCachePath| {
99            let digest_str = v.digest_string(DIGEST_LENGTH).replace('-', "");
100            // We skip the first two characters because they are the hash algorithm identifier
101            digest_str[2..].starts_with(&version_cmp_str)
102        };
103        let paths = cache_paths_for_targets
104            .iter()
105            .filter_map(|(xe, vec_cp)| {
106                let possible_paths = vec_cp
107                    .iter()
108                    .filter(version_cmp)
109                    .cloned()
110                    .collect::<Vec<XvcCachePath>>();
111                if !possible_paths.is_empty() {
112                    Some((*xe, possible_paths))
113                } else {
114                    None
115                }
116            })
117            .fold(
118                Vec::<(XvcEntity, XvcCachePath)>::new(),
119                |mut acc, (xe, vec_cp)| {
120                    vec_cp.into_iter().for_each(|xcp| acc.push((xe, xcp)));
121                    acc
122                },
123            );
124
125        if paths.len() > 1 {
126            return Err(anyhow::anyhow!(
127                "Version prefix is not unique:\n{}",
128                paths
129                    .iter()
130                    .map(|(_, xcp)| xcp.digest_string(DIGEST_LENGTH))
131                    .join("\n")
132            )
133            .into());
134        } else {
135            paths
136        }
137    } else {
138        remove_targets
139            .iter()
140            .filter_map(|(xe, xp)| {
141                all_content_digests
142                    .get(xe)
143                    .map(|cd| (*xe, XvcCachePath::new(xp, cd).unwrap()))
144            })
145            .collect::<Vec<(XvcEntity, XvcCachePath)>>()
146    };
147
148    let mut entities_for_cache_path: HashMap<XvcCachePath, HashSet<XvcEntity>> = HashMap::new();
149
150    for (xe, cache_paths) in all_cache_paths.iter() {
151        for cp in cache_paths {
152            if !entities_for_cache_path.contains_key(cp) {
153                entities_for_cache_path.insert(cp.clone(), HashSet::new());
154            }
155            let entity_set = entities_for_cache_path.get_mut(cp).unwrap();
156            entity_set.insert(*xe);
157        }
158    }
159
160    let mut deletable_paths = Vec::<XvcCachePath>::new();
161    // Report the differences if found
162    let removable_entities: HashSet<XvcEntity> = remove_targets.keys().copied().collect();
163    for (xe, cp) in candidate_paths {
164        let entities_pointing_to_cp =
165            HashSet::from_iter(entities_for_cache_path[&cp].iter().copied());
166        let mut deletable = true;
167        entities_pointing_to_cp
168            .difference(&removable_entities)
169            .for_each(|other_xe| {
170                let this_xp = all_paths.get(&xe).unwrap();
171                let other_xp = all_paths.get(other_xe).unwrap();
172                if opts.force {
173                    warn!(
174                        output_snd,
175                        "Deleting {} (for {}) even though it's also used by {}!",
176                        cp,
177                        this_xp,
178                        other_xp
179                    );
180                } else {
181                    output!(
182                        output_snd,
183                        "Not deleting {} (for {}) because it's also used by {}",
184                        cp,
185                        this_xp,
186                        other_xp
187                    );
188                    deletable = false;
189                }
190            });
191
192        if deletable {
193            deletable_paths.push(cp);
194        }
195    }
196
197    // We sort the paths to have a stable output.
198    deletable_paths.sort_unstable();
199
200    if opts.from_cache {
201        deletable_paths
202            .iter()
203            .for_each(|xcp| uwr!(xcp.remove(output_snd, xvc_root), output_snd));
204    }
205
206    if let Some(storage) = opts.from_storage {
207        let storage = get_storage_record(output_snd, xvc_root, &storage)?;
208        storage.delete(output_snd, xvc_root, deletable_paths.as_slice())?;
209    }
210
211    Ok(())
212}