1use 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
26pub 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
31pub static CARGO_TOML_COMMENT: &str = r#"# This file is generated by `cargo hakari`.
33# To regenerate, run:
34# cargo hakari generate
35"#;
36
37pub static DISABLE_MESSAGE: &str = r#"
39# Disabled by running `cargo hakari disable`.
40# To re-enable, run:
41# cargo hakari generate
42"#;
43
44#[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 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#[derive(Debug, Parser)]
73enum Command {
74 #[clap(name = "init")]
76 Initialize {
77 path: Utf8PathBuf,
79
80 #[clap(long, short)]
82 package_name: Option<String>,
83
84 #[clap(long)]
86 skip_config: bool,
87
88 #[clap(long, short = 'n', conflicts_with = "yes")]
93 dry_run: bool,
94
95 #[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 {
171 #[clap(long)]
175 diff: bool,
176 },
177
178 Verify,
185
186 ManageDeps {
222 #[clap(flatten)]
223 packages: PackageSelection,
224
225 #[clap(long, short = 'n', conflicts_with = "yes")]
230 dry_run: bool,
231
232 #[clap(long, short, conflicts_with = "dry_run")]
234 yes: bool,
235 },
236
237 RemoveDeps {
243 #[clap(flatten)]
244 packages: PackageSelection,
245
246 #[clap(long, short = 'n', conflicts_with = "yes")]
251 dry_run: bool,
252
253 #[clap(long, short, conflicts_with = "dry_run")]
255 yes: bool,
256 },
257
258 Explain {
275 dep_name: String,
277 },
278
279 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
286 Publish {
287 #[clap(long, short)]
289 package: String,
290
291 #[clap(num_args = 0..)]
293 pass_through: Vec<String>,
294 },
295
296 Disable {
300 #[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 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 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#[derive(Debug, Parser)]
470struct PackageSelection {
471 #[clap(long = "package", short)]
472 packages: Vec<String>,
474}
475
476impl PackageSelection {
477 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
487fn 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 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 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}