cargo_msrv_prep/
lib.rs

1//! Cargo subcommand useful to prepare for determining/verifying a crate's MSRV.
2//!
3//! This crate is a library used by the two `cargo` commands provided:
4//!
5//! - `cargo-msrv-prep`
6//! - `cargo-msrv-unprep`
7//!
8//! This library is not meant for external use and makes no guarantee on API stability.
9//!
10//! To install `cargo-msrv-prep`, see [the project's GitHub page](https://github.com/clechasseur/msrv-prep).
11
12#![deny(rustdoc::broken_intra_doc_links)]
13#![deny(rustdoc::private_intra_doc_links)]
14#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
15
16pub mod common_args;
17mod detail;
18pub mod metadata;
19pub(crate) mod mockable;
20pub mod result;
21
22use std::fs;
23
24use cargo_metadata::camino::{Utf8Path, Utf8PathBuf};
25use log::{debug, error, info, trace, warn};
26pub use result::Error;
27pub use result::Result;
28use toml_edit::{ImDocument, Item, Table};
29
30use crate::detail::{merge_msrv_dependencies, PACKAGE_SECTION_NAME};
31#[mockall_double::double]
32use crate::mockable::fs as mockable_fs;
33use crate::result::IoErrorContext;
34
35/// Default suffix used to backup manifest files before determining/verifying MSRV.
36pub const DEFAULT_MANIFEST_BACKUP_SUFFIX: &str = ".msrv-prep.bak";
37
38/// Field in the `package` section of a manifest that stores the package's MSRV.
39pub const RUST_VERSION_SPECIFIER: &str = "rust-version";
40
41/// Default name of a Cargo manifest file.
42pub const DEFAULT_MANIFEST_FILE_NAME: &str = "Cargo.toml";
43
44/// Extension used for lockfiles.
45pub const LOCKFILE_EXT: &str = "lock";
46
47/// Removes the `rust-version` field from a Cargo manifest's
48/// `package` section, if present.
49///
50/// Returns `true` if the manifest was modified.
51pub fn remove_rust_version(manifest: &mut Table) -> bool {
52    trace!("Entering `remove_rust_version`");
53
54    let changed = match manifest.get_mut(PACKAGE_SECTION_NAME) {
55        Some(Item::Table(package)) => {
56            info!(
57                "'package' section found in manifest, removing '{}' field",
58                RUST_VERSION_SPECIFIER
59            );
60
61            package.remove(RUST_VERSION_SPECIFIER).is_some()
62        },
63        _ => false,
64    };
65
66    trace!("Exiting `remove_rust_version` (changed: {})", changed);
67    changed
68}
69
70/// Merges optional MSRV dependencies in a Cargo manifest if they exist.
71///
72/// The optional pinned MSRV dependencies need to be stored in a file next to the Cargo manifest.
73///
74/// Returns `Ok(true)` if the manifest was modified.
75pub fn maybe_merge_msrv_dependencies(
76    manifest: &mut Table,
77    manifest_path: &Utf8Path,
78    pins_file_name: &str,
79) -> Result<bool> {
80    trace!(
81        "Entering `maybe_merge_msrv_dependencies` (manifest_path: '{}', pins_file_name: '{}')",
82        manifest_path,
83        pins_file_name
84    );
85    let mut changed = false;
86
87    let pins_file_path = manifest_path.parent().map(|par| par.join(pins_file_name));
88
89    if let Some(pins_file_path) = pins_file_path {
90        debug!("Pinned MSRV dependencies file path: {}", pins_file_path);
91
92        if pins_file_path.is_file() {
93            info!(
94                "Pinned MSRV dependencies file found at '{}'; merging with manifest at '{}'",
95                pins_file_path, manifest_path
96            );
97
98            let pins_file_text = fs::read_to_string(&pins_file_path)
99                .with_io_context(|| format!("reading MSRV pins file '{}'", pins_file_path))?;
100            let pins_file = ImDocument::parse(pins_file_text)?;
101
102            changed = merge_msrv_dependencies(manifest, &pins_file);
103        }
104    } else {
105        warn!("Pinned MSRV dependencies file path could not be determined; skipping");
106    }
107
108    trace!("Exiting `maybe_merge_msrv_dependencies` (changed: {})", changed);
109    Ok(changed)
110}
111
112/// Backs up a manifest file by copying it to a new file next to it.
113///
114/// The new file's name is the same as the manifest, with the given backup suffix appended.
115///
116/// If a lockfile exists next to the manifest, it is also backed up in a similar manner.
117pub fn backup_manifest(manifest_path: &Utf8Path, backup_suffix: &str, force: bool) -> Result<()> {
118    trace!(
119        "Entering `backup_manifest` (manifest_path: '{}', backup_suffix: '{}', force: {})",
120        manifest_path,
121        backup_suffix,
122        force,
123    );
124
125    let lockfile_path = manifest_path.with_extension(LOCKFILE_EXT);
126
127    let manifest_backup_path = get_backup_path(manifest_path, backup_suffix)?;
128    let lockfile_backup_path = get_backup_path(&lockfile_path, backup_suffix)?;
129
130    validate_backup_file(&manifest_backup_path, force)?;
131    if lockfile_path.is_file() {
132        validate_backup_file(&lockfile_backup_path, force)?;
133    }
134
135    backup_file(manifest_path, &manifest_backup_path)?;
136    if lockfile_path.is_file() {
137        backup_file(&lockfile_path, &lockfile_backup_path)?;
138    }
139
140    trace!("Exiting `backup_manifest`");
141    Ok(())
142}
143
144/// If a backup manifest exists next to the given manifest, restores it.
145///
146/// The backup manifest must've been created by calling [`backup_manifest`]
147/// (passing it the same `backup_suffix` value).
148///
149/// If a lockfile was also backed up next to the manifest, it is also restored.
150pub fn maybe_restore_manifest(manifest_path: &Utf8Path, backup_suffix: &str) -> Result<()> {
151    trace!(
152        "Entering `maybe_restore_manifest` (manifest_path: '{}', backup_suffix: '{}')",
153        manifest_path,
154        backup_suffix
155    );
156
157    let lockfile_path = manifest_path.with_extension(LOCKFILE_EXT);
158
159    maybe_restore_file(manifest_path, backup_suffix)?;
160
161    if lockfile_path.is_file() {
162        maybe_restore_file(&lockfile_path, backup_suffix)?;
163    }
164
165    trace!("Exiting `maybe_restore_manifest`");
166    Ok(())
167}
168
169fn maybe_restore_file(file_path: &Utf8Path, backup_suffix: &str) -> Result<()> {
170    trace!(
171        "Entering `maybe_restore_file` (file_path: '{}', backup_suffix: '{}')",
172        file_path,
173        backup_suffix
174    );
175
176    let backup_path = get_backup_path(file_path, backup_suffix)?;
177    debug!("Backup path: {}", backup_path);
178
179    if backup_path.is_file() {
180        info!("Backup file found at '{}'; restoring to '{}'", backup_path, file_path);
181
182        mockable_fs::rename(&backup_path, file_path).with_io_context(|| {
183            format!("restoring backup from '{}' to '{}'", backup_path, file_path)
184        })?;
185    }
186
187    trace!("Exiting `maybe_restore_file`");
188    Ok(())
189}
190
191fn get_backup_path(file_path: &Utf8Path, backup_suffix: &str) -> Result<Utf8PathBuf> {
192    file_path
193        .file_name()
194        .map(|name| name.to_string() + backup_suffix)
195        .and_then(|name| file_path.parent().map(|par| par.join(name)))
196        .ok_or_else(|| Error::InvalidPath(file_path.into()))
197}
198
199fn validate_backup_file(backup_path: &Utf8Path, force: bool) -> Result<()> {
200    match (backup_path.is_file(), force) {
201        (true, true) => {
202            info!(
203                "Backup file already exists at '{}'; will be overwritten (forced backup)",
204                backup_path
205            );
206            Ok(())
207        },
208        (true, false) => {
209            error!("Backup file already exists at '{}'; aborting", backup_path);
210
211            Err(Error::BackupFileAlreadyExists(backup_path.into()))
212        },
213        (false, _) => Ok(()),
214    }
215}
216
217fn backup_file(file_path: &Utf8Path, backup_path: &Utf8Path) -> Result<()> {
218    info!("Backing up '{}' to '{}'", file_path, backup_path);
219    mockable_fs::copy(file_path, backup_path)
220        .map(|_| ())
221        .with_io_context(|| format!("backing up '{}' to '{}'", file_path, backup_path))
222}
223
224#[cfg(test)]
225#[cfg_attr(coverage_nightly, coverage(off))]
226mod tests {
227    use super::*;
228
229    mod remove_rust_version {
230        use indoc::indoc;
231        use toml_edit::DocumentMut;
232
233        use super::*;
234
235        #[test_log::test]
236        fn no_rust_version() {
237            let manifest_text = indoc! {r#"
238                [table]
239                hangar = 23
240            "#};
241            let mut manifest = manifest_text.parse::<DocumentMut>().unwrap();
242
243            let changed = remove_rust_version(&mut manifest);
244
245            assert!(!changed);
246            assert_eq!(manifest_text, manifest.to_string());
247        }
248    }
249
250    mod maybe_merge_msrv_dependencies {
251        use assert_matches::assert_matches;
252
253        use super::*;
254
255        #[test_log::test]
256        fn skip_parent_path() {
257            let changed =
258                maybe_merge_msrv_dependencies(&mut Table::new(), "".into(), "msrv-pins.toml");
259
260            assert_matches!(changed, Ok(false));
261        }
262    }
263
264    mod backup_manifest {
265        use super::*;
266
267        mod errors {
268            use std::io;
269
270            use assert_matches::assert_matches;
271
272            use super::*;
273
274            #[test_log::test]
275            fn backup_copy_error() {
276                let project_path: Utf8PathBuf = [
277                    env!("CARGO_MANIFEST_DIR"),
278                    "resources",
279                    "tests",
280                    "cargo-msrv-prep",
281                    "simple_project",
282                ]
283                .iter()
284                .collect();
285
286                let ctx = mockable_fs::copy_context();
287                ctx.expect().returning(|_, _| {
288                    Err(io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"))
289                });
290
291                let result = backup_manifest(
292                    &project_path.join("Cargo.toml"),
293                    DEFAULT_MANIFEST_BACKUP_SUFFIX,
294                    true,
295                );
296                assert_matches!(result, Err(Error::Io { source, .. }) => {
297                    assert_eq!(io::ErrorKind::PermissionDenied, source.kind());
298                });
299            }
300        }
301    }
302
303    mod maybe_restore_manifest {
304        use super::*;
305
306        mod errors {
307            use std::io;
308
309            use assert_matches::assert_matches;
310
311            use super::*;
312
313            #[test_log::test]
314            fn backup_rename_error() {
315                let project_path: Utf8PathBuf = [
316                    env!("CARGO_MANIFEST_DIR"),
317                    "resources",
318                    "tests",
319                    "cargo-msrv-unprep",
320                    "simple_project",
321                ]
322                .iter()
323                .collect();
324
325                let ctx = mockable_fs::rename_context();
326                ctx.expect().returning(|_, _| {
327                    Err(io::Error::new(io::ErrorKind::PermissionDenied, "permission denied"))
328                });
329
330                let result = maybe_restore_manifest(
331                    &project_path.join("Cargo.toml"),
332                    DEFAULT_MANIFEST_BACKUP_SUFFIX,
333                );
334                assert_matches!(result, Err(Error::Io { source, .. }) => {
335                    assert_eq!(io::ErrorKind::PermissionDenied, source.kind());
336                });
337            }
338        }
339    }
340}