use crate::{
helpers::{read_contents, regenerate_lockfile},
output::{OutputContext, OutputOpts},
publish::publish_hakari,
};
use camino::{Utf8Path, Utf8PathBuf};
use clap::Parser;
use color_eyre::eyre::{Result, WrapErr, bail, eyre};
use guppy::{
MetadataCommand,
graph::{PackageGraph, PackageSet},
};
use hakari::{
DepFormatVersion, HakariBuilder, HakariCargoToml, HakariOutputOptions, TomlOutError,
cli_ops::{HakariInit, WorkspaceOps},
diffy::PatchFormatter,
summaries::{DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH, HakariConfig},
};
use log::{error, info};
use owo_colors::OwoColorize;
use std::convert::TryFrom;
pub static CONFIG_COMMENT: &str = r#"# This file contains settings for `cargo hakari`.
# See https://docs.rs/cargo-hakari/latest/cargo_hakari/config for a full list of options.
"#;
pub static CARGO_TOML_COMMENT: &str = r#"# This file is generated by `cargo hakari`.
# To regenerate, run:
# cargo hakari generate
"#;
pub static DISABLE_MESSAGE: &str = r#"
# Disabled by running `cargo hakari disable`.
# To re-enable, run:
# cargo hakari generate
"#;
#[derive(Debug, Parser)]
#[clap(author, version, about)]
pub struct Args {
#[clap(flatten)]
global: GlobalOpts,
#[clap(subcommand)]
command: Command,
}
impl Args {
pub fn exec(self) -> Result<i32> {
self.command.exec(self.global.output)
}
}
#[derive(Debug, Parser)]
struct GlobalOpts {
#[clap(flatten)]
output: OutputOpts,
}
#[derive(Debug, Parser)]
enum Command {
#[clap(name = "init")]
Initialize {
path: Utf8PathBuf,
#[clap(long, short)]
package_name: Option<String>,
#[clap(long)]
skip_config: bool,
#[clap(long, short = 'n', conflicts_with = "yes")]
dry_run: bool,
#[clap(long, short, conflicts_with = "dry-run")]
yes: bool,
},
#[clap(flatten)]
WithBuilder(CommandWithBuilder),
}
impl Command {
fn exec(self, output: OutputOpts) -> Result<i32> {
let output = output.init();
let metadata_command = MetadataCommand::new();
let package_graph = metadata_command
.build_graph()
.context("building package graph failed")?;
match self {
Command::Initialize {
path,
package_name,
skip_config,
dry_run,
yes,
} => {
let package_name = match package_name.as_deref() {
Some(name) => name,
None => match path.file_name() {
Some(name) => name,
None => bail!("invalid path {}", path),
},
};
let workspace_path =
cwd_rel_to_workspace_rel(&path, package_graph.workspace().root())?;
let mut init = HakariInit::new(&package_graph, package_name, &workspace_path)
.with_context(|| "error initializing Hakari package")?;
init.set_cargo_toml_comment(CARGO_TOML_COMMENT);
if !skip_config {
init.set_config(DEFAULT_CONFIG_PATH.as_ref(), CONFIG_COMMENT)
.with_context(|| "error initializing Hakari package")?;
}
let ops = init.make_ops();
apply_on_dialog(dry_run, yes, &ops, &output, || {
let steps = [
format!(
"* configure at {}",
DEFAULT_CONFIG_PATH.style(output.styles.config_path),
),
format!(
"* run {} to generate contents",
"cargo hakari generate".style(output.styles.command),
),
format!(
"* run {} to add dependency lines",
"cargo hakari manage-deps".style(output.styles.command),
),
];
info!("next steps:\n{}\n", steps.join("\n"));
Ok(())
})
}
Command::WithBuilder(cmd) => {
let (builder, hakari_output) = make_builder_and_output(&package_graph)?;
cmd.exec(builder, hakari_output, output)
}
}
}
}
#[derive(Debug, Parser)]
enum CommandWithBuilder {
Generate {
#[clap(long)]
diff: bool,
},
Verify,
ManageDeps {
#[clap(flatten)]
packages: PackageSelection,
#[clap(long, short = 'n', conflicts_with = "yes")]
dry_run: bool,
#[clap(long, short, conflicts_with = "dry_run")]
yes: bool,
},
RemoveDeps {
#[clap(flatten)]
packages: PackageSelection,
#[clap(long, short = 'n', conflicts_with = "yes")]
dry_run: bool,
#[clap(long, short, conflicts_with = "dry_run")]
yes: bool,
},
Explain {
dep_name: String,
},
#[clap(trailing_var_arg = true, allow_hyphen_values = true)]
Publish {
#[clap(long, short)]
package: String,
#[clap(num_args = 0..)]
pass_through: Vec<String>,
},
Disable {
#[clap(long)]
diff: bool,
},
}
impl CommandWithBuilder {
fn exec(
self,
builder: HakariBuilder<'_>,
hakari_output: HakariOutputOptions,
output: OutputContext,
) -> Result<i32> {
let hakari_package = *builder
.hakari_package()
.expect("hakari-package must be specified in hakari.toml");
match self {
CommandWithBuilder::Generate { diff } => {
let package_graph = builder.graph();
let hakari = builder.compute();
let toml_out = match hakari.to_toml_string(&hakari_output) {
Ok(toml_out) => toml_out,
Err(TomlOutError::UnrecognizedRegistry {
package_id,
registry_url,
}) => {
let package = package_graph
.metadata(&package_id)
.expect("package ID obtained from the same graph");
error!(
"unrecognized registry URL {} found for {} v{}\n\
(add to [registries] section of {})",
registry_url.style(output.styles.registry_url),
package.name().style(output.styles.package_name),
package.version().style(output.styles.package_version),
"hakari.toml".style(output.styles.config_path),
);
return Ok(102);
}
Err(
err @ TomlOutError::Platform(_)
| err @ TomlOutError::Toml { .. }
| err @ TomlOutError::FmtWrite(_)
| err @ TomlOutError::UnrecognizedExternal { .. }
| err @ TomlOutError::PathWithoutHakari { .. }
| err,
) => Err(err).with_context(|| "error generating new hakari.toml")?,
};
let existing_toml = hakari
.read_toml()
.expect("hakari-package must be specified")?;
let exit_code =
write_to_cargo_toml(existing_toml, &toml_out, diff, output.clone())?;
if hakari.builder().dep_format_version() < DepFormatVersion::latest() {
info!(
"new hakari format version available: {latest} (current: {})\n\
(add or update `dep-format-version = \"{latest}\"` in {}, then run \
`cargo hakari generate && cargo hakari manage-deps`)",
hakari.builder().dep_format_version(),
"hakari.toml".style(output.styles.config_path),
latest = DepFormatVersion::latest(),
);
}
Ok(exit_code)
}
CommandWithBuilder::Verify => match builder.verify() {
Ok(()) => {
info!(
"{} works correctly",
hakari_package.name().style(output.styles.package_name),
);
Ok(0)
}
Err(errs) => {
let mut display = errs.display();
if output.color.is_enabled() {
display.colorize();
}
info!(
"{} didn't work correctly:\n{}",
hakari_package.name().style(output.styles.package_name),
display,
);
Ok(1)
}
},
CommandWithBuilder::ManageDeps {
packages,
dry_run,
yes,
} => {
let ops = builder
.manage_dep_ops(&packages.to_package_set(builder.graph())?)
.expect("hakari-package must be specified in hakari.toml");
if ops.is_empty() {
info!("no operations to perform");
return Ok(0);
}
apply_on_dialog(dry_run, yes, &ops, &output, || {
regenerate_lockfile(output.clone())
})
}
CommandWithBuilder::RemoveDeps {
packages,
dry_run,
yes,
} => {
let ops = builder
.remove_dep_ops(&packages.to_package_set(builder.graph())?, false)
.expect("hakari-package must be specified in hakari.toml");
if ops.is_empty() {
info!("no operations to perform");
return Ok(0);
}
apply_on_dialog(dry_run, yes, &ops, &output, || {
regenerate_lockfile(output.clone())
})
}
CommandWithBuilder::Explain {
dep_name: crate_name,
} => {
let hakari = builder.compute();
let toml_name_map = hakari.toml_name_map();
let dep = toml_name_map.get(crate_name.as_str()).ok_or_else(|| {
eyre!(
"crate name '{}' not found in workspace-hack\n\
(hint: check spelling, or regenerate workspace-hack with `cargo hakari generate`)",
crate_name
)
})?;
let explain = hakari
.explain(dep.id())
.expect("package ID should be known since it was in the output");
let mut display = explain.display();
if output.color.is_enabled() {
display.colorize();
}
info!("\n{display}");
Ok(0)
}
CommandWithBuilder::Publish {
package,
pass_through,
} => {
publish_hakari(&package, builder, &pass_through, output)?;
Ok(0)
}
CommandWithBuilder::Disable { diff } => {
let existing_toml = builder
.read_toml()
.expect("hakari-package must be specified")?;
write_to_cargo_toml(existing_toml, DISABLE_MESSAGE, diff, output)
}
}
}
}
#[derive(Debug, Parser)]
struct PackageSelection {
#[clap(long = "package", short)]
packages: Vec<String>,
}
impl PackageSelection {
fn to_package_set<'g>(&self, graph: &'g PackageGraph) -> Result<PackageSet<'g>> {
if !self.packages.is_empty() {
Ok(graph.resolve_workspace_names(&self.packages)?)
} else {
Ok(graph.resolve_workspace())
}
}
}
fn cwd_rel_to_workspace_rel(path: &Utf8Path, workspace_root: &Utf8Path) -> Result<Utf8PathBuf> {
let abs_path = if path.is_absolute() {
path.to_owned()
} else {
let cwd = std::env::current_dir().with_context(|| "could not access current dir")?;
let mut cwd = Utf8PathBuf::try_from(cwd).with_context(|| "current dir is invalid UTF-8")?;
cwd.push(path);
cwd
};
abs_path
.strip_prefix(workspace_root)
.map(|p| p.to_owned())
.with_context(|| format!("path {abs_path} is not inside workspace root {workspace_root}"))
}
fn make_builder_and_output(
package_graph: &PackageGraph,
) -> Result<(HakariBuilder<'_>, HakariOutputOptions)> {
let (config_path, contents) = read_contents(
package_graph.workspace().root(),
[DEFAULT_CONFIG_PATH, FALLBACK_CONFIG_PATH],
)
.wrap_err("error reading Hakari config")?;
let config: HakariConfig = contents
.parse()
.wrap_err_with(|| format!("error deserializing Hakari config at {config_path}"))?;
let builder = config
.builder
.to_hakari_builder(package_graph)
.wrap_err_with(|| format!("error resolving Hakari config at {config_path}"))?;
let hakari_output = config.output.to_options();
Ok((builder, hakari_output))
}
fn write_to_cargo_toml(
existing_toml: HakariCargoToml,
new_contents: &str,
diff: bool,
output: OutputContext,
) -> Result<i32> {
if diff {
let patch = existing_toml.diff_toml(new_contents);
if patch.hunks().is_empty() {
Ok(0)
} else {
let mut formatter = PatchFormatter::new();
if output.color.is_enabled() {
formatter = formatter.with_color();
}
info!("\n{}", formatter.fmt_patch(&patch));
Ok(1)
}
} else {
if !existing_toml.is_changed(new_contents) {
info!("no changes detected");
} else {
existing_toml
.write_to_file(new_contents)
.with_context(|| "error writing updated Hakari contents")?;
info!("contents updated");
regenerate_lockfile(output)?;
}
Ok(0)
}
}
fn apply_on_dialog(
dry_run: bool,
yes: bool,
ops: &WorkspaceOps<'_, '_>,
output: &OutputContext,
after: impl FnOnce() -> Result<()>,
) -> Result<i32> {
let mut display = ops.display();
if output.color.is_enabled() {
display.colorize();
}
info!("operations to perform:\n\n{display}");
if dry_run {
return Ok(1);
}
let should_apply = if yes {
true
} else {
let colorful_theme = dialoguer::theme::ColorfulTheme::default();
let confirm = if output.color.is_enabled() {
dialoguer::Confirm::with_theme(&colorful_theme)
} else {
dialoguer::Confirm::with_theme(&dialoguer::theme::SimpleTheme)
};
confirm
.with_prompt("proceed?")
.default(true)
.show_default(true)
.interact()
.with_context(|| "error reading input")?
};
if should_apply {
ops.apply()?;
after()?;
Ok(0)
} else {
Ok(1)
}
}