Skip to main content

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::{Result, WrapErr, bail, eyre};
12use guppy::{
13    MetadataCommand,
14    graph::{PackageGraph, PackageSet},
15};
16use hakari::{
17    DepFormatVersion, HakariBuilder, HakariCargoToml, HakariOutputOptions, TomlOutError,
18    cli_ops::{HakariInit, WorkspaceOps},
19    diffy::PatchFormatter,
20    summaries::{DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH, HakariConfig},
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    ///
191    /// A workspace crate is *managed* if it isn't the workspace-hack itself and
192    /// isn't listed in `traversal-excludes` or `final-excludes`. For each
193    /// selected crate, what happens depends on its existing dependency on the
194    /// workspace-hack, if any:
195    ///
196    /// ```text
197    /// Crate     Existing dependency on workspace-hack    Action
198    /// --------  ---------------------------------------  ---------------------------
199    /// managed   none                                     add to [dependencies]
200    /// managed   only [dev-dependencies] and/or           add to [dependencies], and
201    ///           [build-dependencies] (top-level or       remove the [dev-dependencies]
202    ///           under [target.*])                        line
203    /// managed   unconditional [dependencies]             keep; update the line if
204    ///                                                    needed (see below)
205    /// managed   only under [target.*], or optional       keep as is
206    /// excluded  any                                      remove from every section,
207    ///                                                    including [target.*]
208    /// excluded  none                                     nothing
209    /// ```
210    ///
211    /// A line needs updating if its version requirement doesn't match the
212    /// workspace-hack's version, or if it has no version requirement and
213    /// `workspace-hack-line-style` isn't `"workspace-dotted"`.
214    ///
215    /// Platform-specific lines under `[target.*]` and optional lines count as
216    /// present and are never rewritten, so crates that deliberately gate the
217    /// workspace-hack (for example, to keep it out of builds on certain
218    /// platforms) are left alone. A `[build-dependencies]` line is retained
219    /// when adding dependency lines, since it pulls the workspace-hack into the
220    /// host build on purpose.
221    ManageDeps {
222        #[clap(flatten)]
223        packages: PackageSelection,
224
225        /// Print operations that need to be performed, but do not actually perform them.
226        ///
227        /// Exits with status 1 if any operations need to be performed. Can be combined with
228        /// `--quiet`.
229        #[clap(long, short = 'n', conflicts_with = "yes")]
230        dry_run: bool,
231
232        /// Proceed with the operation without prompting for confirmation.
233        #[clap(long, short, conflicts_with = "dry_run")]
234        yes: bool,
235    },
236
237    /// Remove dependencies from workspace crates to workspace-hack.
238    ///
239    /// The dependency is removed from every section it appears in, including
240    /// `[dev-dependencies]`, `[build-dependencies]` and the sections under
241    /// `[target.*]`.
242    RemoveDeps {
243        #[clap(flatten)]
244        packages: PackageSelection,
245
246        /// Print operations that need to be performed, but do not actually perform them.
247        ///
248        /// Exits with status 1 if any operations need to be performed. Can be combined with
249        /// `--quiet`.
250        #[clap(long, short = 'n', conflicts_with = "yes")]
251        dry_run: bool,
252
253        /// Proceed with the operation without prompting for confirmation.
254        #[clap(long, short, conflicts_with = "dry_run")]
255        yes: bool,
256    },
257
258    /// Print out workspace crates responsible for adding a dependency to workspace-hack.
259    ///
260    /// For a dependency to be included in the workspace-hack, it must have been built with at least
261    /// two different feature sets by different crates in the workspace (unless the
262    /// output-single-feature option is set to true). The explain command prints out a table
263    /// consisting of the different feature sets that got built; and, for each feature set, the
264    /// workspace crates and options that resulted in it.
265    ///
266    /// Adding the initial set of dependencies to the workspace-hack can cause further dependencies
267    /// to be added if they're built with a second feature set. These cases are marked as
268    /// "post-compute fixup".
269    ///
270    /// Currently, this command only prints out the different feature sets that get built for a
271    /// dependency, and the workspace crates responsible for them. Further investigation can be done
272    /// through `cargo tree`. In the future, the scope of this command may be extended to provide
273    /// information about intermediate dependencies as well.
274    Explain {
275        /// The name of the dependency, as present in the workspace-hack.
276        dep_name: String,
277    },
278
279    /// Publish a package after temporarily removing the workspace-hack dependency from it.
280    ///
281    /// For more information about publishing options,
282    /// see {n}https://docs.rs/cargo-hakari/latest/cargo_hakari/publishing.
283    ///
284    /// Trailing arguments are passed through to cargo publish.
285    #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
286    Publish {
287        /// The name of the package to publish.
288        #[clap(long, short)]
289        package: String,
290
291        /// Arguments to pass through to `cargo publish`.
292        #[clap(num_args = 0..)]
293        pass_through: Vec<String>,
294    },
295
296    /// Disables the workspace-hack crate.
297    ///
298    /// Removes all the generated contents from the workspace-hack crate.
299    Disable {
300        /// Print a diff of changes instead of writing them out. Can be combined with `--quiet`.
301        ///
302        /// Exits with status 1 if the contents are different.
303        #[clap(long)]
304        diff: bool,
305    },
306}
307
308impl CommandWithBuilder {
309    fn exec(
310        self,
311        builder: HakariBuilder<'_>,
312        hakari_output: HakariOutputOptions,
313        output: OutputContext,
314    ) -> Result<i32> {
315        let hakari_package = *builder
316            .hakari_package()
317            .expect("hakari-package must be specified in hakari.toml");
318
319        match self {
320            CommandWithBuilder::Generate { diff } => {
321                let package_graph = builder.graph();
322                let hakari = builder.compute();
323                let toml_out = match hakari.to_toml_string(&hakari_output) {
324                    Ok(toml_out) => toml_out,
325                    Err(TomlOutError::UnrecognizedRegistry {
326                        package_id,
327                        registry_url,
328                    }) => {
329                        // Print out a better error message for this more common use case.
330                        let package = package_graph
331                            .metadata(&package_id)
332                            .expect("package ID obtained from the same graph");
333                        error!(
334                            "unrecognized registry URL {} found for {} v{}\n\
335                             (add to [registries] section of {})",
336                            registry_url.style(output.styles.registry_url),
337                            package.name().style(output.styles.package_name),
338                            package.version().style(output.styles.package_version),
339                            "hakari.toml".style(output.styles.config_path),
340                        );
341                        // 102 is picked pretty arbitrarily because regular errors exit with 101.
342                        return Ok(102);
343                    }
344                    Err(
345                        err @ TomlOutError::Platform(_)
346                        | err @ TomlOutError::Toml { .. }
347                        | err @ TomlOutError::FmtWrite(_)
348                        | err @ TomlOutError::UnrecognizedExternal { .. }
349                        | err @ TomlOutError::PathWithoutHakari { .. }
350                        | err,
351                    ) => Err(err).with_context(|| "error generating new hakari.toml")?,
352                };
353
354                let existing_toml = hakari
355                    .read_toml()
356                    .expect("hakari-package must be specified")?;
357
358                let exit_code =
359                    write_to_cargo_toml(existing_toml, &toml_out, diff, output.clone())?;
360                if hakari.builder().dep_format_version() < DepFormatVersion::latest() {
361                    info!(
362                        "new hakari format version available: {latest} (current: {})\n\
363                        (add or update `dep-format-version = \"{latest}\"` in {}, then run \
364                        `cargo hakari generate && cargo hakari manage-deps`)",
365                        hakari.builder().dep_format_version(),
366                        "hakari.toml".style(output.styles.config_path),
367                        latest = DepFormatVersion::latest(),
368                    );
369                }
370
371                Ok(exit_code)
372            }
373            CommandWithBuilder::Verify => match builder.verify() {
374                Ok(()) => {
375                    info!(
376                        "{} works correctly",
377                        hakari_package.name().style(output.styles.package_name),
378                    );
379                    Ok(0)
380                }
381                Err(errs) => {
382                    let mut display = errs.display();
383                    if output.color.is_enabled() {
384                        display.colorize();
385                    }
386                    info!(
387                        "{} didn't work correctly:\n{}",
388                        hakari_package.name().style(output.styles.package_name),
389                        display,
390                    );
391                    Ok(1)
392                }
393            },
394            CommandWithBuilder::ManageDeps {
395                packages,
396                dry_run,
397                yes,
398            } => {
399                let ops = builder
400                    .manage_dep_ops(&packages.to_package_set(builder.graph())?)
401                    .expect("hakari-package must be specified in hakari.toml");
402                if ops.is_empty() {
403                    info!("no operations to perform");
404                    return Ok(0);
405                }
406
407                apply_on_dialog(dry_run, yes, &ops, &output, || {
408                    regenerate_lockfile(output.clone())
409                })
410            }
411            CommandWithBuilder::RemoveDeps {
412                packages,
413                dry_run,
414                yes,
415            } => {
416                let ops = builder
417                    .remove_dep_ops(&packages.to_package_set(builder.graph())?, false)
418                    .expect("hakari-package must be specified in hakari.toml");
419                if ops.is_empty() {
420                    info!("no operations to perform");
421                    return Ok(0);
422                }
423
424                apply_on_dialog(dry_run, yes, &ops, &output, || {
425                    regenerate_lockfile(output.clone())
426                })
427            }
428            CommandWithBuilder::Explain {
429                dep_name: crate_name,
430            } => {
431                let hakari = builder.compute();
432                let toml_name_map = hakari.toml_name_map();
433                let dep = toml_name_map.get(crate_name.as_str()).ok_or_else(|| {
434                    eyre!(
435                        "crate name '{}' not found in workspace-hack\n\
436                        (hint: check spelling, or regenerate workspace-hack with `cargo hakari generate`)",
437                        crate_name
438                    )
439                })?;
440
441                let explain = hakari
442                    .explain(dep.id())
443                    .expect("package ID should be known since it was in the output");
444                let mut display = explain.display();
445                if output.color.is_enabled() {
446                    display.colorize();
447                }
448                info!("\n{display}");
449                Ok(0)
450            }
451            CommandWithBuilder::Publish {
452                package,
453                pass_through,
454            } => {
455                publish_hakari(&package, builder, &pass_through, output)?;
456                Ok(0)
457            }
458            CommandWithBuilder::Disable { diff } => {
459                let existing_toml = builder
460                    .read_toml()
461                    .expect("hakari-package must be specified")?;
462                write_to_cargo_toml(existing_toml, DISABLE_MESSAGE, diff, output)
463            }
464        }
465    }
466}
467
468/// Support for packages and features.
469#[derive(Debug, Parser)]
470struct PackageSelection {
471    #[clap(long = "package", short)]
472    /// Packages to operate on (default: entire workspace)
473    packages: Vec<String>,
474}
475
476impl PackageSelection {
477    /// Converts this selection into a `PackageSet`.
478    fn to_package_set<'g>(&self, graph: &'g PackageGraph) -> Result<PackageSet<'g>> {
479        if !self.packages.is_empty() {
480            Ok(graph.resolve_workspace_names(&self.packages)?)
481        } else {
482            Ok(graph.resolve_workspace())
483        }
484    }
485}
486
487// ---
488// Helper methods
489// ---
490
491fn cwd_rel_to_workspace_rel(path: &Utf8Path, workspace_root: &Utf8Path) -> Result<Utf8PathBuf> {
492    let abs_path = if path.is_absolute() {
493        path.to_owned()
494    } else {
495        let cwd = std::env::current_dir().with_context(|| "could not access current dir")?;
496        let mut cwd = Utf8PathBuf::try_from(cwd).with_context(|| "current dir is invalid UTF-8")?;
497        cwd.push(path);
498        cwd
499    };
500
501    abs_path
502        .strip_prefix(workspace_root)
503        .map(|p| p.to_owned())
504        .with_context(|| format!("path {abs_path} is not inside workspace root {workspace_root}"))
505}
506
507fn make_builder_and_output(
508    package_graph: &PackageGraph,
509) -> Result<(HakariBuilder<'_>, HakariOutputOptions)> {
510    let (config_path, contents) = read_contents(
511        package_graph.workspace().root(),
512        [DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH],
513    )
514    .wrap_err("error reading Hakari config")?;
515
516    let config: HakariConfig = contents
517        .parse()
518        .wrap_err_with(|| format!("error deserializing Hakari config at {config_path}"))?;
519
520    let builder = config
521        .builder
522        .to_hakari_builder(package_graph)
523        .wrap_err_with(|| format!("error resolving Hakari config at {config_path}"))?;
524    let hakari_output = config.output.to_options();
525
526    Ok((builder, hakari_output))
527}
528
529fn write_to_cargo_toml(
530    existing_toml: HakariCargoToml,
531    new_contents: &str,
532    diff: bool,
533    output: OutputContext,
534) -> Result<i32> {
535    if diff {
536        let patch = existing_toml.diff_toml(new_contents);
537        if patch.hunks().is_empty() {
538            // No differences.
539            Ok(0)
540        } else {
541            let mut formatter = PatchFormatter::new();
542            if output.color.is_enabled() {
543                formatter = formatter.with_color();
544            }
545            info!("\n{}", formatter.fmt_patch(&patch));
546            Ok(1)
547        }
548    } else {
549        if !existing_toml.is_changed(new_contents) {
550            info!("no changes detected");
551        } else {
552            existing_toml
553                .write_to_file(new_contents)
554                .with_context(|| "error writing updated Hakari contents")?;
555            info!("contents updated");
556            regenerate_lockfile(output)?;
557        }
558        Ok(0)
559    }
560}
561
562fn apply_on_dialog(
563    dry_run: bool,
564    yes: bool,
565    ops: &WorkspaceOps<'_, '_>,
566    output: &OutputContext,
567    after: impl FnOnce() -> Result<()>,
568) -> Result<i32> {
569    let mut display = ops.display();
570    if output.color.is_enabled() {
571        display.colorize();
572    }
573    info!("operations to perform:\n\n{display}");
574
575    if dry_run {
576        // dry-run + non-empty ops implies exit status 1.
577        return Ok(1);
578    }
579
580    let should_apply = if yes {
581        true
582    } else {
583        let colorful_theme = dialoguer::theme::ColorfulTheme::default();
584        let confirm = if output.color.is_enabled() {
585            dialoguer::Confirm::with_theme(&colorful_theme)
586        } else {
587            dialoguer::Confirm::with_theme(&dialoguer::theme::SimpleTheme)
588        };
589        confirm
590            .with_prompt("proceed?")
591            .default(true)
592            .show_default(true)
593            .interact()
594            .with_context(|| "error reading input")?
595    };
596
597    if should_apply {
598        ops.apply()?;
599        after()?;
600        Ok(0)
601    } else {
602        Ok(1)
603    }
604}