cargo_hakari/
command.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    helpers::{read_contents, regenerate_lockfile},
6    output::{OutputContext, OutputOpts},
7    publish::publish_hakari,
8};
9use camino::{Utf8Path, Utf8PathBuf};
10use clap::Parser;
11use color_eyre::eyre::{bail, eyre, Result, WrapErr};
12use guppy::{
13    graph::{PackageGraph, PackageSet},
14    MetadataCommand,
15};
16use hakari::{
17    cli_ops::{HakariInit, WorkspaceOps},
18    diffy::PatchFormatter,
19    summaries::{HakariConfig, DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH},
20    DepFormatVersion, HakariBuilder, HakariCargoToml, HakariOutputOptions, TomlOutError,
21};
22use log::{error, info};
23use owo_colors::OwoColorize;
24use std::convert::TryFrom;
25
26/// The comment to add to the top of the config file.
27pub static CONFIG_COMMENT: &str = r#"# This file contains settings for `cargo hakari`.
28# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options.
29"#;
30
31/// The comment to add to the top of the workspace-hack package's Cargo.toml.
32pub static CARGO_TOML_COMMENT: &str = r#"# This file is generated by `cargo hakari`.
33# To regenerate, run:
34#     cargo hakari generate
35"#;
36
37/// The message to write into a disabled Cargo.toml.
38pub static DISABLE_MESSAGE: &str = r#"
39# Disabled by running `cargo hakari disable`.
40# To re-enable, run:
41#     cargo hakari generate
42"#;
43
44/// Set up and manage workspace-hack crates.
45///
46/// For more about cargo-hakari, see <https://docs.rs/cargo-hakari>.
47#[derive(Debug, Parser)]
48#[clap(author, version, about)]
49pub struct Args {
50    #[clap(flatten)]
51    global: GlobalOpts,
52    #[clap(subcommand)]
53    command: Command,
54}
55
56impl Args {
57    /// Executes the command.
58    ///
59    /// Returns the exit status, or an error on failure.
60    pub fn exec(self) -> Result<i32> {
61        self.command.exec(self.global.output)
62    }
63}
64
65#[derive(Debug, Parser)]
66struct GlobalOpts {
67    #[clap(flatten)]
68    output: OutputOpts,
69}
70
71/// Manage workspace-hack crates.
72#[derive(Debug, Parser)]
73enum Command {
74    /// Initialize a workspace-hack crate and a hakari.toml file
75    #[clap(name = "init")]
76    Initialize {
77        /// Path to generate the workspace-hack crate at, relative to the current directory.
78        path: Utf8PathBuf,
79
80        /// The name of the crate (default: derived from path)
81        #[clap(long, short)]
82        package_name: Option<String>,
83
84        /// Skip writing a stub config to hakari.toml
85        #[clap(long)]
86        skip_config: bool,
87
88        /// Print operations that need to be performed, but do not actually perform them.
89        ///
90        /// Exits with status 1 if any operations need to be performed. Can be combined with
91        /// `--quiet`.
92        #[clap(long, short = 'n', conflicts_with = "yes")]
93        dry_run: bool,
94
95        /// Proceed with the operation without prompting for confirmation.
96        #[clap(long, short, conflicts_with = "dry-run")]
97        yes: bool,
98    },
99
100    #[clap(flatten)]
101    WithBuilder(CommandWithBuilder),
102}
103
104impl Command {
105    fn exec(self, output: OutputOpts) -> Result<i32> {
106        let output = output.init();
107        let metadata_command = MetadataCommand::new();
108        let package_graph = metadata_command
109            .build_graph()
110            .context("building package graph failed")?;
111
112        match self {
113            Command::Initialize {
114                path,
115                package_name,
116                skip_config,
117                dry_run,
118                yes,
119            } => {
120                let package_name = match package_name.as_deref() {
121                    Some(name) => name,
122                    None => match path.file_name() {
123                        Some(name) => name,
124                        None => bail!("invalid path {}", path),
125                    },
126                };
127
128                let workspace_path =
129                    cwd_rel_to_workspace_rel(&path, package_graph.workspace().root())?;
130
131                let mut init = HakariInit::new(&package_graph, package_name, &workspace_path)
132                    .with_context(|| "error initializing Hakari package")?;
133                init.set_cargo_toml_comment(CARGO_TOML_COMMENT);
134                if !skip_config {
135                    init.set_config(DEFAULT_CONFIG_PATH.as_ref(), CONFIG_COMMENT)
136                        .with_context(|| "error initializing Hakari package")?;
137                }
138
139                let ops = init.make_ops();
140                apply_on_dialog(dry_run, yes, &ops, &output, || {
141                    let steps = [
142                        format!(
143                            "* configure at {}",
144                            DEFAULT_CONFIG_PATH.style(output.styles.config_path),
145                        ),
146                        format!(
147                            "* run {} to generate contents",
148                            "cargo hakari generate".style(output.styles.command),
149                        ),
150                        format!(
151                            "* run {} to add dependency lines",
152                            "cargo hakari manage-deps".style(output.styles.command),
153                        ),
154                    ];
155                    info!("next steps:\n{}\n", steps.join("\n"));
156                    Ok(())
157                })
158            }
159            Command::WithBuilder(cmd) => {
160                let (builder, hakari_output) = make_builder_and_output(&package_graph)?;
161                cmd.exec(builder, hakari_output, output)
162            }
163        }
164    }
165}
166
167#[derive(Debug, Parser)]
168enum CommandWithBuilder {
169    /// Generate or update the contents of the workspace-hack crate
170    Generate {
171        /// Print a diff of contents instead of writing them out. Can be combined with `--quiet`.
172        ///
173        /// Exits with status 1 if the contents are different.
174        #[clap(long)]
175        diff: bool,
176    },
177
178    /// Perform verification of the workspace-hack crate
179    ///
180    /// Check that the workspace-hack crate succeeds at its goal of building one version of
181    /// every non-omitted third-party crate.
182    ///
183    /// Exits with status 1 if verification failed.
184    Verify,
185
186    /// Manage dependencies from workspace crates to workspace-hack.
187    ///
188    /// * Add the dependency to all non-excluded workspace crates.
189    /// * Remove the dependency from all excluded workspace crates.
190    ManageDeps {
191        #[clap(flatten)]
192        packages: PackageSelection,
193
194        /// Print operations that need to be performed, but do not actually perform them.
195        ///
196        /// Exits with status 1 if any operations need to be performed. Can be combined with
197        /// `--quiet`.
198        #[clap(long, short = 'n', conflicts_with = "yes")]
199        dry_run: bool,
200
201        /// Proceed with the operation without prompting for confirmation.
202        #[clap(long, short, conflicts_with = "dry_run")]
203        yes: bool,
204    },
205
206    /// Remove dependencies from workspace crates to workspace-hack.
207    RemoveDeps {
208        #[clap(flatten)]
209        packages: PackageSelection,
210
211        /// Print operations that need to be performed, but do not actually perform them.
212        ///
213        /// Exits with status 1 if any operations need to be performed. Can be combined with
214        /// `--quiet`.
215        #[clap(long, short = 'n', conflicts_with = "yes")]
216        dry_run: bool,
217
218        /// Proceed with the operation without prompting for confirmation.
219        #[clap(long, short, conflicts_with = "dry_run")]
220        yes: bool,
221    },
222
223    /// Print out workspace crates responsible for adding a dependency to workspace-hack.
224    ///
225    /// For a dependency to be included in the workspace-hack, it must have been built with at least
226    /// two different feature sets by different crates in the workspace (unless the
227    /// output-single-feature option is set to true). The explain command prints out a table
228    /// consisting of the different feature sets that got built; and, for each feature set, the
229    /// workspace crates and options that resulted in it.
230    ///
231    /// Adding the initial set of dependencies to the workspace-hack can cause further dependencies
232    /// to be added if they're built with a second feature set. These cases are marked as
233    /// "post-compute fixup".
234    ///
235    /// Currently, this command only prints out the different feature sets that get built for a
236    /// dependency, and the workspace crates responsible for them. Further investigation can be done
237    /// through `cargo tree`. In the future, the scope of this command may be extended to provide
238    /// information about intermediate dependencies as well.
239    Explain {
240        /// The name of the dependency, as present in the workspace-hack.
241        dep_name: String,
242    },
243
244    /// Publish a package after temporarily removing the workspace-hack dependency from it.
245    ///
246    /// For more information about publishing options,
247    /// see {n}https://docs.rs/cargo-hakari/latest/cargo_hakari/publishing.
248    ///
249    /// Trailing arguments are passed through to cargo publish.
250    #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
251    Publish {
252        /// The name of the package to publish.
253        #[clap(long, short)]
254        package: String,
255
256        /// Arguments to pass through to `cargo publish`.
257        #[clap(num_args = 0..)]
258        pass_through: Vec<String>,
259    },
260
261    /// Disables the workspace-hack crate.
262    ///
263    /// Removes all the generated contents from the workspace-hack crate.
264    Disable {
265        /// Print a diff of changes instead of writing them out. Can be combined with `--quiet`.
266        ///
267        /// Exits with status 1 if the contents are different.
268        #[clap(long)]
269        diff: bool,
270    },
271}
272
273impl CommandWithBuilder {
274    fn exec(
275        self,
276        builder: HakariBuilder<'_>,
277        hakari_output: HakariOutputOptions,
278        output: OutputContext,
279    ) -> Result<i32> {
280        let hakari_package = *builder
281            .hakari_package()
282            .expect("hakari-package must be specified in hakari.toml");
283
284        match self {
285            CommandWithBuilder::Generate { diff } => {
286                let package_graph = builder.graph();
287                let hakari = builder.compute();
288                let toml_out = match hakari.to_toml_string(&hakari_output) {
289                    Ok(toml_out) => toml_out,
290                    Err(TomlOutError::UnrecognizedRegistry {
291                        package_id,
292                        registry_url,
293                    }) => {
294                        // Print out a better error message for this more common use case.
295                        let package = package_graph
296                            .metadata(&package_id)
297                            .expect("package ID obtained from the same graph");
298                        error!(
299                            "unrecognized registry URL {} found for {} v{}\n\
300                             (add to [registries] section of {})",
301                            registry_url.style(output.styles.registry_url),
302                            package.name().style(output.styles.package_name),
303                            package.version().style(output.styles.package_version),
304                            "hakari.toml".style(output.styles.config_path),
305                        );
306                        // 102 is picked pretty arbitrarily because regular errors exit with 101.
307                        return Ok(102);
308                    }
309                    Err(
310                        err @ TomlOutError::Platform(_)
311                        | err @ TomlOutError::Toml { .. }
312                        | err @ TomlOutError::FmtWrite(_)
313                        | err @ TomlOutError::UnrecognizedExternal { .. }
314                        | err @ TomlOutError::PathWithoutHakari { .. }
315                        | err,
316                    ) => Err(err).with_context(|| "error generating new hakari.toml")?,
317                };
318
319                let existing_toml = hakari
320                    .read_toml()
321                    .expect("hakari-package must be specified")?;
322
323                let exit_code =
324                    write_to_cargo_toml(existing_toml, &toml_out, diff, output.clone())?;
325                if hakari.builder().dep_format_version() < DepFormatVersion::latest() {
326                    info!(
327                        "new hakari format version available: {latest} (current: {})\n\
328                        (add or update `dep-format-version = \"{latest}\"` in {}, then run \
329                        `cargo hakari generate && cargo hakari manage-deps`)",
330                        hakari.builder().dep_format_version(),
331                        "hakari.toml".style(output.styles.config_path),
332                        latest = DepFormatVersion::latest(),
333                    );
334                }
335
336                Ok(exit_code)
337            }
338            CommandWithBuilder::Verify => match builder.verify() {
339                Ok(()) => {
340                    info!(
341                        "{} works correctly",
342                        hakari_package.name().style(output.styles.package_name),
343                    );
344                    Ok(0)
345                }
346                Err(errs) => {
347                    let mut display = errs.display();
348                    if output.color.is_enabled() {
349                        display.colorize();
350                    }
351                    info!(
352                        "{} didn't work correctly:\n{}",
353                        hakari_package.name().style(output.styles.package_name),
354                        display,
355                    );
356                    Ok(1)
357                }
358            },
359            CommandWithBuilder::ManageDeps {
360                packages,
361                dry_run,
362                yes,
363            } => {
364                let ops = builder
365                    .manage_dep_ops(&packages.to_package_set(builder.graph())?)
366                    .expect("hakari-package must be specified in hakari.toml");
367                if ops.is_empty() {
368                    info!("no operations to perform");
369                    return Ok(0);
370                }
371
372                apply_on_dialog(dry_run, yes, &ops, &output, || {
373                    regenerate_lockfile(output.clone())
374                })
375            }
376            CommandWithBuilder::RemoveDeps {
377                packages,
378                dry_run,
379                yes,
380            } => {
381                let ops = builder
382                    .remove_dep_ops(&packages.to_package_set(builder.graph())?, false)
383                    .expect("hakari-package must be specified in hakari.toml");
384                if ops.is_empty() {
385                    info!("no operations to perform");
386                    return Ok(0);
387                }
388
389                apply_on_dialog(dry_run, yes, &ops, &output, || {
390                    regenerate_lockfile(output.clone())
391                })
392            }
393            CommandWithBuilder::Explain {
394                dep_name: crate_name,
395            } => {
396                let hakari = builder.compute();
397                let toml_name_map = hakari.toml_name_map();
398                let dep = toml_name_map.get(crate_name.as_str()).ok_or_else(|| {
399                    eyre!(
400                        "crate name '{}' not found in workspace-hack\n\
401                        (hint: check spelling, or regenerate workspace-hack with `cargo hakari generate`)",
402                        crate_name
403                    )
404                })?;
405
406                let explain = hakari
407                    .explain(dep.id())
408                    .expect("package ID should be known since it was in the output");
409                let mut display = explain.display();
410                if output.color.is_enabled() {
411                    display.colorize();
412                }
413                info!("\n{}", display);
414                Ok(0)
415            }
416            CommandWithBuilder::Publish {
417                package,
418                pass_through,
419            } => {
420                publish_hakari(&package, builder, &pass_through, output)?;
421                Ok(0)
422            }
423            CommandWithBuilder::Disable { diff } => {
424                let existing_toml = builder
425                    .read_toml()
426                    .expect("hakari-package must be specified")?;
427                write_to_cargo_toml(existing_toml, DISABLE_MESSAGE, diff, output)
428            }
429        }
430    }
431}
432
433/// Support for packages and features.
434#[derive(Debug, Parser)]
435struct PackageSelection {
436    #[clap(long = "package", short)]
437    /// Packages to operate on (default: entire workspace)
438    packages: Vec<String>,
439}
440
441impl PackageSelection {
442    /// Converts this selection into a `PackageSet`.
443    fn to_package_set<'g>(&self, graph: &'g PackageGraph) -> Result<PackageSet<'g>> {
444        if !self.packages.is_empty() {
445            Ok(graph.resolve_workspace_names(&self.packages)?)
446        } else {
447            Ok(graph.resolve_workspace())
448        }
449    }
450}
451
452// ---
453// Helper methods
454// ---
455
456fn cwd_rel_to_workspace_rel(path: &Utf8Path, workspace_root: &Utf8Path) -> Result<Utf8PathBuf> {
457    let abs_path = if path.is_absolute() {
458        path.to_owned()
459    } else {
460        let cwd = std::env::current_dir().with_context(|| "could not access current dir")?;
461        let mut cwd = Utf8PathBuf::try_from(cwd).with_context(|| "current dir is invalid UTF-8")?;
462        cwd.push(path);
463        cwd
464    };
465
466    abs_path
467        .strip_prefix(workspace_root)
468        .map(|p| p.to_owned())
469        .with_context(|| {
470            format!(
471                "path {} is not inside workspace root {}",
472                abs_path, workspace_root
473            )
474        })
475}
476
477fn make_builder_and_output(
478    package_graph: &PackageGraph,
479) -> Result<(HakariBuilder<'_>, HakariOutputOptions)> {
480    let (config_path, contents) = read_contents(
481        package_graph.workspace().root(),
482        [DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH],
483    )
484    .wrap_err("error reading Hakari config")?;
485
486    let config: HakariConfig = contents
487        .parse()
488        .wrap_err_with(|| format!("error deserializing Hakari config at {}", config_path))?;
489
490    let builder = config
491        .builder
492        .to_hakari_builder(package_graph)
493        .wrap_err_with(|| format!("error resolving Hakari config at {}", config_path))?;
494    let hakari_output = config.output.to_options();
495
496    Ok((builder, hakari_output))
497}
498
499fn write_to_cargo_toml(
500    existing_toml: HakariCargoToml,
501    new_contents: &str,
502    diff: bool,
503    output: OutputContext,
504) -> Result<i32> {
505    if diff {
506        let patch = existing_toml.diff_toml(new_contents);
507        if patch.hunks().is_empty() {
508            // No differences.
509            Ok(0)
510        } else {
511            let mut formatter = PatchFormatter::new();
512            if output.color.is_enabled() {
513                formatter = formatter.with_color();
514            }
515            info!("\n{}", formatter.fmt_patch(&patch));
516            Ok(1)
517        }
518    } else {
519        if !existing_toml.is_changed(new_contents) {
520            info!("no changes detected");
521        } else {
522            existing_toml
523                .write_to_file(new_contents)
524                .with_context(|| "error writing updated Hakari contents")?;
525            info!("contents updated");
526            regenerate_lockfile(output)?;
527        }
528        Ok(0)
529    }
530}
531
532fn apply_on_dialog(
533    dry_run: bool,
534    yes: bool,
535    ops: &WorkspaceOps<'_, '_>,
536    output: &OutputContext,
537    after: impl FnOnce() -> Result<()>,
538) -> Result<i32> {
539    let mut display = ops.display();
540    if output.color.is_enabled() {
541        display.colorize();
542    }
543    info!("operations to perform:\n\n{}", display);
544
545    if dry_run {
546        // dry-run + non-empty ops implies exit status 1.
547        return Ok(1);
548    }
549
550    let should_apply = if yes {
551        true
552    } else {
553        let colorful_theme = dialoguer::theme::ColorfulTheme::default();
554        let confirm = if output.color.is_enabled() {
555            dialoguer::Confirm::with_theme(&colorful_theme)
556        } else {
557            dialoguer::Confirm::with_theme(&dialoguer::theme::SimpleTheme)
558        };
559        confirm
560            .with_prompt("proceed?")
561            .default(true)
562            .show_default(true)
563            .interact()
564            .with_context(|| "error reading input")?
565    };
566
567    if should_apply {
568        ops.apply()?;
569        after()?;
570        Ok(0)
571    } else {
572        Ok(1)
573    }
574}