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}