cargo_parcel/
lib.rs

1//! A library implementing an extended version of `cargo install`.
2//!
3//! The intended use of this crate is in an
4//! [`xtask`](https://github.com/matklad/cargo-xtask)-style helper crate,
5//! extending `cargo` with a `parcel` subcommand, providing a superset of the
6//! functionality provided by `cargo install`. For such typical use, a single
7//! call to the [`main`] function is sufficient to implement the helper crate's
8//! main (and only) program.
9//!
10//! The rest of the API is provided for experimentation with alternative
11//! interfaces to the functionality provided, but should be considered in flux;
12//! no particular considerations to backward compatibility will be made,
13//! although breaking API changes will result in a semver bump, following the
14//! usual Rust conventions.
15
16#![deny(missing_docs, unsafe_code)]
17#![warn(rust_2018_idioms)]
18
19use std::{
20    fmt, fs,
21    path::{Path, PathBuf},
22};
23
24use pico_args::Arguments;
25
26pub use anyhow::Error;
27
28mod bundle;
29
30mod config;
31pub use config::Config;
32
33mod install;
34use install::InstallOpt;
35
36pub mod util;
37
38mod man;
39pub use man::{ManSource, ManSourceFormat, ParseManSourceError};
40
41/// A description of a parcel's metadata.
42#[derive(Debug, Clone)]
43pub struct Parcel {
44    pkg_name: String,
45    pkg_version: String,
46    man_pages: Vec<ManSource>,
47    pkg_data: Vec<PathBuf>,
48    cargo_binaries: Vec<PathBuf>,
49}
50
51/// An action to be carried out.
52///
53/// Created from command-line arguments by `Parcel::action_from_env`.
54#[derive(Debug)]
55pub struct Action(ActionInner);
56
57impl Action {
58    fn help(text: &'static str) -> Action {
59        Action(ActionInner::Help(text))
60    }
61
62    fn bundle(opt: bundle::Options) -> Action {
63        Action(ActionInner::Bundle(Box::new(opt)))
64    }
65
66    fn install(opt: InstallOpt) -> Action {
67        Action(ActionInner::Install(opt))
68    }
69
70    fn report(opt: InstallOpt) -> Action {
71        Action(ActionInner::Report(opt))
72    }
73
74    fn uninstall(opt: InstallOpt) -> Action {
75        Action(ActionInner::Uninstall(opt))
76    }
77
78    /// Execute the action.
79    pub fn run(&self) -> Result<(), Error> {
80        use self::ActionInner::*;
81        match &self.0 {
82            Help(text) => {
83                println!("{}", text);
84            }
85            Bundle(opt) => bundle::create(opt)?,
86            Install(opt) => opt.install()?,
87            Report(opt) => opt.report(),
88            Uninstall(opt) => opt.uninstall()?,
89        }
90        Ok(())
91    }
92}
93
94#[derive(Debug)]
95enum ActionInner {
96    Install(InstallOpt),
97    Report(InstallOpt),
98    Uninstall(InstallOpt),
99    Help(&'static str),
100    Bundle(Box<bundle::Options>),
101}
102
103/// Describes an error caused by an invalid man page specification.
104#[derive(Debug)]
105pub struct ManPageError {
106    source: String,
107    error: Error,
108}
109
110impl fmt::Display for ManPageError {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(
113            f,
114            "malformed man page source file name '{}': {}",
115            self.source, self.error
116        )
117    }
118}
119
120impl std::error::Error for ManPageError {}
121
122/// Describes an error caused by a invalid pkg-data item.
123#[derive(Debug)]
124pub struct PkgDataError {
125    item: String,
126    error: Error,
127}
128
129impl fmt::Display for PkgDataError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(
132            f,
133            "could not resolve package data item '{}': {}",
134            self.item, self.error
135        )
136    }
137}
138
139impl std::error::Error for PkgDataError {}
140
141/// A builder allowing to configuration of optional parcel properties.
142// We can re-use `Parcel` here; this works since we always maintain a valid
143// state.
144#[derive(Debug)]
145pub struct ParcelBuilder(Parcel);
146
147impl ParcelBuilder {
148    /// Configures the cargo binaries.
149    pub fn cargo_binaries<I>(mut self, bins: I) -> Self
150    where
151        I: IntoIterator,
152        I::Item: AsRef<str>,
153    {
154        for bin in bins {
155            self.0.cargo_binaries.push(bin.as_ref().into())
156        }
157        self
158    }
159
160    /// Configures the man pages to install.
161    pub fn man_pages<I>(mut self, sources: I) -> Result<Self, ManPageError>
162    where
163        I: IntoIterator,
164        I::Item: AsRef<str>,
165    {
166        for source in sources {
167            let source = source.as_ref();
168            let parsed = source.parse::<ManSource>().map_err(|e| ManPageError {
169                source: source.to_owned(),
170                error: e.into(),
171            })?;
172            self.0.man_pages.push(parsed);
173        }
174        Ok(self)
175    }
176
177    /// Configures data files and directories to be installed in an
178    /// application-specific directory.
179    ///
180    /// The strings passed will be expanded as glob expressions, and need to
181    /// refer to existing files or directories, relative to the crate root
182    /// directory. The resulting list of paths will be copied, recursively, in
183    /// the case of directories into the application-specific data directory
184    /// below the installation prefix. Note they will be placed in that
185    /// directory using their file name, ignoring any leading directory
186    /// components.
187    pub fn pkg_data<I>(mut self, pkg_data: I) -> Result<Self, PkgDataError>
188    where
189        I: IntoIterator,
190        I::Item: AsRef<str>,
191    {
192        for item in pkg_data {
193            let paths = glob::glob_with(
194                item.as_ref(),
195                glob::MatchOptions {
196                    case_sensitive: true,
197                    require_literal_separator: true,
198                    require_literal_leading_dot: true,
199                },
200            )
201            .map_err(|e| PkgDataError {
202                item: item.as_ref().to_owned(),
203                error: e.into(),
204            })?;
205            for path in paths {
206                let path = path.map_err(|e| PkgDataError {
207                    item: item.as_ref().to_owned(),
208                    error: e.into(),
209                })?;
210                self.0.pkg_data.push(path);
211            }
212        }
213        Ok(self)
214    }
215
216    /// Finishes configuration and returns the parcel as configured.
217    pub fn finish(self) -> Parcel {
218        self.0
219    }
220}
221
222impl Parcel {
223    /// Create an empty parcel.
224    ///
225    /// The given name and version strings will be used to determine
226    /// installation paths for resources.
227    pub fn build<T: Into<String>, U: Into<String>>(pkg_name: T, version: U) -> ParcelBuilder {
228        ParcelBuilder(Parcel {
229            pkg_name: pkg_name.into(),
230            pkg_version: version.into(),
231            man_pages: Vec::new(),
232            pkg_data: Vec::new(),
233            cargo_binaries: Vec::new(),
234        })
235    }
236
237    /// Returns the package name.
238    pub fn pkg_name(&self) -> &str {
239        &self.pkg_name
240    }
241
242    /// Returns the package version string.
243    pub fn pkg_version(&self) -> &str {
244        &self.pkg_version
245    }
246
247    /// Returns the man pages to be installed.
248    pub fn pkg_data(&self) -> impl Iterator<Item = &Path> {
249        self.pkg_data.iter().map(|s| s.as_ref())
250    }
251
252    /// Returns the cargo binaries to be installed.
253    pub fn cargo_binaries(&self) -> impl Iterator<Item = &Path> {
254        self.cargo_binaries.iter().map(|s| s.as_ref())
255    }
256
257    /// Returns the man pages to be installed.
258    pub fn man_pages(&self) -> impl Iterator<Item = &ManSource> {
259        self.man_pages.iter()
260    }
261
262    /// Constructs an action from the command line arguments.
263    pub fn action_from_env(&self) -> Result<Action, Error> {
264        let subcommand = match std::env::args_os().nth(1) {
265            None => return Ok(Action::help(GLOBAL_HELP)),
266            Some(s) => s,
267        };
268        let mut matches = Arguments::from_vec(std::env::args_os().skip(2).collect());
269        let subcommand = &*subcommand.to_string_lossy();
270        let action = match subcommand {
271            "bundle" => {
272                if matches.contains(["-h", "--help"]) {
273                    Action::help(BUNDLE_HELP)
274                } else {
275                    let opt = bundle::Options::from_args(matches, self)?;
276                    Action::bundle(opt)
277                }
278            }
279            "install" => {
280                if matches.contains(["-h", "--help"]) {
281                    Action::help(INSTALL_HELP)
282                } else {
283                    let opt = InstallOpt::from_args(matches, self)?;
284                    Action::install(opt)
285                }
286            }
287            "report" => {
288                if matches.contains(["-h", "--help"]) {
289                    Action::help(REPORT_HELP)
290                } else {
291                    let opt = InstallOpt::from_args(matches, self)?;
292                    Action::report(opt)
293                }
294            }
295            "uninstall" => {
296                if matches.contains(["-h", "--help"]) {
297                    Action::help(UNINSTALL_HELP)
298                } else {
299                    let opt = InstallOpt::from_args(matches, self)?;
300                    Action::uninstall(opt)
301                }
302            }
303            "help" => {
304                let help_text = match matches
305                    .opt_free_from_os_str(|s| s.to_str().map(String::from).ok_or_else(|| UsageError))?
306                {
307                    None => GLOBAL_HELP,
308                    Some(cmd) => match cmd.as_str() {
309                        "bundle" => BUNDLE_HELP,
310                        "install" => INSTALL_HELP,
311                        "uninstall" => UNINSTALL_HELP,
312                        _ => return Err(UsageError.into()),
313                    },
314                };
315                Action::help(help_text)
316            }
317            _ => return Err(UsageError.into()),
318        };
319        Ok(action)
320    }
321}
322
323#[derive(Debug)]
324struct UsageError;
325
326impl fmt::Display for UsageError {
327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328        f.write_str(GLOBAL_HELP)
329    }
330}
331
332impl std::error::Error for UsageError {}
333
334fn run() -> Result<(), Error> {
335    let manifest_contents = fs::read("Cargo.toml")?;
336    let manifest: toml::value::Table = toml::from_slice(&manifest_contents)?;
337    let config = Config::from_manifest(&manifest)?;
338    let parcel = Parcel::build(config.package_name(), config.package_version())
339        .cargo_binaries(config.cargo_binaries())
340        .man_pages(config.man_pages())?
341        .pkg_data(config.pkg_data())?
342        .finish();
343    let action = parcel.action_from_env()?;
344    action.run()?;
345    Ok(())
346}
347
348/// Run the command line interface.
349///
350/// This is the top-level entry point to the library's functionality, and the
351/// only function needed inside a typical helper crate when using cargo-parcel
352/// to provide an extended version of `cargo install` for a project.
353pub fn main() -> ! {
354    let rc = match run() {
355        Ok(()) => 0,
356        Err(e) => {
357            eprintln!("{}", e);
358            1
359        }
360    };
361    std::process::exit(rc);
362}
363
364static GLOBAL_HELP: &str = r#"Extended cargo installer
365
366USAGE:
367
368    cargo parcel [SUBCOMMAND] [OPTIONS]
369
370The available subcommands are:
371
372    install      Install the parcel contents. Default prefix is ~/.local.
373    uninstall    Uninstall the parcel contents.
374    bundle       Create a distribution bundle.
375
376See 'cargo parcel help <command> for more information on a specific command.
377"#;
378
379static BUNDLE_HELP: &str = r#"Create a binary distribution bundle
380
381USAGE:
382
383    cargo parcel bundle [OPTIONS]
384
385OPTIONS:
386
387    --verbose         Verbose operation.
388    --prefix <DIR>    Installation prefix. Defaults to ~/.local.
389    --root <DIR>      Top-level directory of the created archive. Defaults
390                      to <NAME>-<VERSION>.
391    -o <FILE>         Output file. The following extensions are
392                      supported: ".tar", ".tar.gz", ".tar.bz2", ".tar.xz"
393                      and ".tar.zstd". Defaults to <NAME>-<VERSION>.tar.gz.
394                      If "-", an uncompressed TAR archive will be written to
395                      standard output.
396    --tar <CMD>       Command to invoke for GNU tar. Defaults to "tar".
397
398Create a binary distribution bundle, either as TAR format archive on standard
399output, or, when the "-o" option is given, as an archive with a format based on
400the extension of the given file. The "--prefix" option is the same as for the
401"install" command.
402
403This command requires GNU tar. If GNU tar is not available as "tar", you can
404specify an alternative, such "gtar" on BSD platforms, using the "--tar" option.
405"#;
406
407static INSTALL_HELP: &str = r#"Install a parcel
408
409USAGE:
410
411    cargo parcel install [OPTIONS]
412
413OPTIONS:
414
415    --verbose           Verbose operation.
416    --prefix <DIR>      Installation prefix. This defaults to ~/.local.
417    --target <TARGET>   Rust target triple to build for.
418    --no-strip          Do not strip the binaries.
419    --dest-dir <DIR>    Destination directory, will be prepended to the
420                        installation prefix.
421
422This will, after compiling the crate in release mode, install the parcel
423contents as described by the "package.metadata.parcel" section in
424"Cargo.toml". The files will be installed into "<DESTDIR>/<PREFIX>", where
425DESTDIR and PREFIX are specified with the "--dest-dir" and "--prefix" arguments,
426respectively. If "--dest-dir" is not given, it defaults to the root directory.
427
428PREFIX should correspond to the final installation directory, and may be
429compiled into the binaries. DESTDIR can be used to direct the install to a
430staging area.
431"#;
432
433static UNINSTALL_HELP: &str = r#"Uninstall a parcel
434
435USAGE:
436
437    cargo parcel uninstall [OPTIONS]
438
439OPTIONS:
440
441    --verbose           Verbose operation.
442    --prefix <DIR>      Installation prefix. This defaults to ~/.local.
443    --dest-dir <DIR>    Destination directory, will be prepended to the
444                        installation prefix.
445
446This will uninstall the parcel contents installed by the "install" command. The
447"--prefix" and "--dest-dir" arguments should be the same as given to the
448"install" invocation that should be counteracted.
449"#;
450
451static REPORT_HELP: &str = "Report what would get installed";