cargo_hackerman/
opts.rs

1use bpaf::{doc::Style, positional, short, Bpaf, Parser};
2use cargo_metadata::Metadata;
3use semver::Version;
4use std::{path::PathBuf, str::FromStr};
5use tracing::Level;
6
7const DETAILED_HELP: &[(&str, Style)] = &[
8    ("You can pass ", Style::Text),
9    ("--help", Style::Literal),
10    (" twice for more detailed help", Style::Text),
11];
12
13#[derive(Debug, Clone, Bpaf)]
14#[bpaf(options("hackerman"), version, footer(DETAILED_HELP))]
15/// A collection of tools that help your workspace to compile fast
16pub enum Action {
17    #[bpaf(command)]
18    /// Unify crate dependencies across individual crates in the workspace
19    ///
20    ///
21    /// You can undo those changes using `cargo hackerman restore`.
22    ///
23    ///
24    /// `cargo-hackerman hack` calculates and adds a minimal set of extra dependencies
25    /// to all the workspace members such that features of all the dependencies
26    /// of this crate stay the same when it is used as part of the whole workspace
27    /// or by itself.
28    ///
29    /// Once dependencies are hacked you should restore them before making any
30    /// changes.
31    Hack {
32        #[bpaf(external(profile))]
33        profile: Profile,
34
35        /// Don't perform action, only display it
36        dry: bool,
37
38        /// Include dependencies checksum into stash
39        ///
40        /// This helps to ensure you can go back to original (unhacked) dependencies: to be able to
41        /// restore the original dependencies hackerman needs to have them stashed in `Cargo.toml`
42        /// file. If CI detects checksum mismatch this means dependencies were updated on hacked
43        /// sources. You should instead restore them, update and hack again.
44        ///
45        /// You can make locking the default behavior by adding this to `Cargo.toml` in the
46        /// workspace
47        ///
48        /// ```text
49        /// [workspace.metadata.hackerman]
50        /// lock = true
51        /// ```
52        ///
53        lock: bool,
54
55        /// Don't unify dev dependencies
56        #[bpaf(short('D'), long)]
57        no_dev: bool,
58    },
59
60    /// Remove crate dependency unification added by the `hack` command
61    #[bpaf(command)]
62    Restore {
63        #[bpaf(external(profile))]
64        profile: Profile,
65
66        /// Restore individual files instead of the whole workspace
67        #[bpaf(positional("TOML"))]
68        separate: Vec<PathBuf>,
69    },
70
71    /// Check if unification is required and if checksums are correct
72    ///
73    /// Similar to `cargo-hackerman hack --dry`, but also sets exit status to 1
74    /// so you can use it as part of CI process
75    #[bpaf(command)]
76    Check {
77        #[bpaf(external(profile))]
78        profile: Profile,
79
80        /// Don't unify dev dependencies
81        #[bpaf(short('D'), long)]
82        no_dev: bool,
83    },
84
85    /// Restore files and merge with the default merge driver
86    ///
87    ///
88    ///
89    ///
90    /// To use it you would add something like this to `~/.gitconfig` or `.git/config`
91    ///
92    /// ```text
93    /// [merge "hackerman"]
94    /// name = merge restored files with hackerman
95    /// driver = cargo hackerman merge %O %A %B %P
96    /// ```
97    ///
98    /// And something like this to `.git/gitattributes`
99    ///
100    /// ```text
101    /// Cargo.toml merge=hackerman
102    /// ```
103    #[bpaf(command("merge"))]
104    MergeDriver {
105        #[bpaf(positional("BASE"))]
106        base: PathBuf,
107        #[bpaf(positional("LOCAL"))]
108        local: PathBuf,
109        #[bpaf(positional("REMOTE"))]
110        remote: PathBuf,
111        #[bpaf(positional("RESULT"))]
112        result: PathBuf,
113    },
114
115    #[bpaf(command)]
116    /// Explain why some dependency is present. Both feature and version are optional
117    ///
118    ///
119    ///
120    ///
121    ///
122    /// With large amount of dependencies it might be difficult to tell why exactly some
123    /// sub-sub-sub dependency is included. hackerman explain solves this problem by tracing
124    /// the dependency chain from the target and to the workspace.
125    ///
126    /// `explain` starts at a given crate/feature and follows reverse dependency links until it
127    /// reaches all the crossing points with the workspace but without entering the workspace
128    /// itself.
129    ///
130    /// White nodes represent workspace members, round nodes represent features, octagonal nodes
131    /// represent base crates. Dotted line represents dev-only dependency, dashed line - both
132    /// dev and normal but with different features across them. Target is usually highlighted.
133    /// By default hackerman expands packages info feature nodes which can be reverted with
134    /// `-P` and tries to reduce transitive dependencies to keep the tree more readable -
135    /// this can be reverted with `-T`.
136    ///
137    /// If a crate is present in several versions you can specify version of the one you
138    /// are interested in but it's optional.
139    ///
140    /// You can also specify which feature to look for, otherwise hackerman will be
141    /// looking for all of them.
142    Explain {
143        #[bpaf(external(profile))]
144        profile: Profile,
145
146        /// Don't strip redundant links
147        #[bpaf(short('T'), long)]
148        no_transitive_opt: bool,
149
150        /// Use package nodes instead of feature nodes
151        #[bpaf(short('P'), long)]
152        package_nodes: bool,
153
154        /// Print dot file to stdout instead of spawning `xdot`
155        #[bpaf(short, long)]
156        stdout: bool,
157
158        #[bpaf(positional("CRATE"))]
159        krate: String,
160        #[bpaf(external(feature_if))]
161        feature: Option<String>,
162        #[bpaf(external(version_if))]
163        version: Option<Version>,
164    },
165
166    /// Lists all the duplicates in the workspace
167    #[bpaf(command)]
168    Dupes {
169        #[bpaf(external(profile))]
170        profile: Profile,
171    },
172
173    #[bpaf(command)]
174    /// Make a tree out of dependencies
175    ///
176    ///
177    ///
178    ///
179    /// Examples:
180    ///
181    /// ```sh
182    /// cargo hackerman tree rand 0.8.4
183    /// cargo hackerman tree serde_json preserve_order
184    /// ```
185    Tree {
186        #[bpaf(external(profile))]
187        profile: Profile,
188
189        /// Don't strip redundant links
190        #[bpaf(short('T'), long)]
191        no_transitive_opt: bool,
192
193        /// Don't include dev dependencies
194        #[bpaf(short('D'), long)]
195        no_dev: bool,
196
197        /// Use package nodes instead of feature nodes
198        #[bpaf(short('P'), long)]
199        package_nodes: bool,
200
201        /// Keep within the workspace
202        #[bpaf(short, long)]
203        workspace: bool,
204
205        /// Print dot file to stdout instead of spawning `xdot`
206        #[bpaf(short, long)]
207        stdout: bool,
208
209        #[bpaf(positional("CRATE"))]
210        krate: Option<String>,
211        #[bpaf(external(feature_if))]
212        feature: Option<String>,
213        #[bpaf(external(version_if))]
214        version: Option<Version>,
215    },
216
217    #[bpaf(command("show"))]
218    /// Show crate manifest, readme, repository or documentation
219    ///
220    ///
221    ///
222    ///
223    /// Examples:
224    ///
225    /// ```sh
226    /// cargo hackerman show --repository syn
227    /// ```
228    ShowCrate {
229        #[bpaf(external(profile))]
230        profile: Profile,
231        #[bpaf(external(focus), fallback(Focus::Manifest))]
232        focus: Focus,
233        #[bpaf(positional("CRATE"))]
234        krate: String,
235        #[bpaf(external(version_if))]
236        version: Option<Version>,
237    },
238}
239
240fn feature_if() -> impl Parser<Option<String>> {
241    positional::<String>("FEATURE")
242        .parse::<_, _, &'static str>(|s| match Version::from_str(&s) {
243            Err(_) => Ok(s),
244            Ok(_) => Err("not a feature"),
245        })
246        .optional()
247        .catch()
248}
249
250fn version_if() -> impl Parser<Option<Version>> {
251    positional::<Version>("VERSION").optional().catch()
252}
253
254#[derive(Debug, Clone, Bpaf)]
255/// Cargo options:
256#[bpaf(custom_usage(&[("CARGO_OPTS", Style::Metavar)]))]
257pub struct Profile {
258    #[bpaf(argument("PATH"), fallback("Cargo.toml".into()))]
259    /// Path to Cargo.toml file
260    pub manifest_path: PathBuf,
261
262    /// Require Cargo.lock and cache are up to date
263    pub frozen: bool,
264    /// Require Cargo.lock is up to date
265    pub locked: bool,
266    /// Run without accessing the network
267    pub offline: bool,
268
269    #[bpaf(external)]
270    pub verbosity: (usize, Level),
271}
272
273impl Profile {
274    pub fn exec(&self) -> anyhow::Result<Metadata> {
275        let mut cmd = cargo_metadata::MetadataCommand::new();
276
277        let mut extra = Vec::new();
278        if self.frozen {
279            extra.push(String::from("--frozen"));
280        }
281        if self.locked {
282            extra.push(String::from("--locked"));
283        }
284        if self.offline {
285            extra.push(String::from("--offline"));
286        }
287        for _ in 0..self.verbosity.0 {
288            extra.push(String::from("-v"));
289        }
290        cmd.manifest_path(&self.manifest_path);
291        cmd.other_options(extra);
292
293        Ok(cmd.exec()?)
294    }
295}
296
297#[derive(Debug, Clone, Bpaf)]
298pub enum Focus {
299    #[bpaf(short, long)]
300    /// Show crate manifest
301    Manifest,
302
303    #[bpaf(short, long)]
304    /// Show crate readme
305    Readme,
306
307    #[bpaf(short, long("doc"), long("docs"))]
308    /// Open documentation URL
309    Documentation,
310
311    #[bpaf(short('R'), long, long("repo"), long("git"))]
312    /// Repository
313    Repository,
314}
315
316fn verbosity() -> impl Parser<(usize, Level)> {
317    short('v')
318        .long("verbose")
319        .help("increase verbosity, can be used several times")
320        .req_flag(())
321        .count()
322        .map(|x| {
323            (
324                x,
325                match x {
326                    0 => Level::WARN,
327                    1 => Level::INFO,
328                    2 => Level::DEBUG,
329                    _ => Level::TRACE,
330                },
331            )
332        })
333}
334
335#[cfg(all(test, unix))]
336mod readme {
337
338    fn write_updated(new_val: &str, path: impl AsRef<std::path::Path>) -> std::io::Result<bool> {
339        use std::io::Read;
340        use std::io::Seek;
341        let mut file = std::fs::OpenOptions::new()
342            .write(true)
343            .read(true)
344            .create(true)
345            .open(path)?;
346        let mut current_val = String::new();
347        file.read_to_string(&mut current_val)?;
348        if current_val != new_val {
349            file.set_len(0)?;
350            file.seek(std::io::SeekFrom::Start(0))?;
351            std::io::Write::write_all(&mut file, new_val.as_bytes())?;
352            Ok(false)
353        } else {
354            Ok(true)
355        }
356    }
357
358    #[test]
359    fn docs_are_up_to_date() {
360        let usage = super::action().render_markdown("cargo hackerman");
361        let readme = std::fs::read_to_string("README.tpl").unwrap();
362        let docs = readme.replacen("<USAGE>", &usage, 1);
363        assert!(write_updated(&docs, "README.md").unwrap());
364    }
365}