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::{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
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 {
191 #[clap(flatten)]
192 packages: PackageSelection,
193
194 #[clap(long, short = 'n', conflicts_with = "yes")]
199 dry_run: bool,
200
201 #[clap(long, short, conflicts_with = "dry_run")]
203 yes: bool,
204 },
205
206 RemoveDeps {
208 #[clap(flatten)]
209 packages: PackageSelection,
210
211 #[clap(long, short = 'n', conflicts_with = "yes")]
216 dry_run: bool,
217
218 #[clap(long, short, conflicts_with = "dry_run")]
220 yes: bool,
221 },
222
223 Explain {
240 dep_name: String,
242 },
243
244 #[clap(trailing_var_arg = true, allow_hyphen_values = true)]
251 Publish {
252 #[clap(long, short)]
254 package: String,
255
256 #[clap(num_args = 0..)]
258 pass_through: Vec<String>,
259 },
260
261 Disable {
265 #[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 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 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#[derive(Debug, Parser)]
435struct PackageSelection {
436 #[clap(long = "package", short)]
437 packages: Vec<String>,
439}
440
441impl PackageSelection {
442 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
452fn 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 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 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}