1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
use bpaf::{doc::Style, positional, short, Bpaf, Parser};
use cargo_metadata::Metadata;
use semver::Version;
use std::{path::PathBuf, str::FromStr};
use tracing::Level;

const DETAILED_HELP: &[(&str, Style)] = &[
    ("You can pass ", Style::Text),
    ("--help", Style::Literal),
    (" twice for more detailed help", Style::Text),
];

#[derive(Debug, Clone, Bpaf)]
#[bpaf(options("hackerman"), version, footer(DETAILED_HELP))]
/// A collection of tools that help your workspace to compile fast
pub enum Action {
    #[bpaf(command)]
    /// Unify crate dependencies across individual crates in the workspace
    ///
    ///
    /// You can undo those changes using `cargo hackerman restore`.
    ///
    ///
    /// `cargo-hackerman hack` calculates and adds a minimal set of extra dependencies
    /// to all the workspace members such that features of all the dependencies
    /// of this crate stay the same when it is used as part of the whole workspace
    /// or by itself.
    ///
    /// Once dependencies are hacked you should restore them before making any
    /// changes.
    Hack {
        #[bpaf(external(profile))]
        profile: Profile,

        /// Don't perform action, only display it
        dry: bool,

        /// Include dependencies checksum into stash
        ///
        /// This helps to ensure you can go back to original (unhacked) dependencies: to be able to
        /// restore the original dependencies hackerman needs to have them stashed in `Cargo.toml`
        /// file. If CI detects checksum mismatch this means dependencies were updated on hacked
        /// sources. You should instead restore them, update and hack again.
        ///
        /// You can make locking the default behavior by adding this to `Cargo.toml` in the
        /// workspace
        ///
        /// ```text
        /// [workspace.metadata.hackerman]
        /// lock = true
        /// ```
        ///
        lock: bool,

        /// Don't unify dev dependencies
        #[bpaf(short('D'), long)]
        no_dev: bool,
    },

    /// Remove crate dependency unification added by the `hack` command
    #[bpaf(command)]
    Restore {
        #[bpaf(external(profile))]
        profile: Profile,

        /// Restore individual files instead of the whole workspace
        #[bpaf(positional("TOML"))]
        separate: Vec<PathBuf>,
    },

    /// Check if unification is required and if checksums are correct
    ///
    /// Similar to `cargo-hackerman hack --dry`, but also sets exit status to 1
    /// so you can use it as part of CI process
    #[bpaf(command)]
    Check {
        #[bpaf(external(profile))]
        profile: Profile,

        /// Don't unify dev dependencies
        #[bpaf(short('D'), long)]
        no_dev: bool,
    },

    /// Restore files and merge with the default merge driver
    ///
    ///
    ///
    ///
    /// To use it you would add something like this to `~/.gitconfig` or `.git/config`
    ///
    /// ```text
    /// [merge "hackerman"]
    /// name = merge restored files with hackerman
    /// driver = cargo hackerman merge %O %A %B %P
    /// ```
    ///
    /// And something like this to `.git/gitattributes`
    ///
    /// ```text
    /// Cargo.toml merge=hackerman
    /// ```
    #[bpaf(command("merge"))]
    MergeDriver {
        #[bpaf(positional("BASE"))]
        base: PathBuf,
        #[bpaf(positional("LOCAL"))]
        local: PathBuf,
        #[bpaf(positional("REMOTE"))]
        remote: PathBuf,
        #[bpaf(positional("RESULT"))]
        result: PathBuf,
    },

    #[bpaf(command)]
    /// Explain why some dependency is present. Both feature and version are optional
    ///
    ///
    ///
    ///
    ///
    /// With large amount of dependencies it might be difficult to tell why exactly some
    /// sub-sub-sub dependency is included. hackerman explain solves this problem by tracing
    /// the dependency chain from the target and to the workspace.
    ///
    /// `explain` starts at a given crate/feature and follows reverse dependency links until it
    /// reaches all the crossing points with the workspace but without entering the workspace
    /// itself.
    ///
    /// White nodes represent workspace members, round nodes represent features, octagonal nodes
    /// represent base crates. Dotted line represents dev-only dependency, dashed line - both
    /// dev and normal but with different features across them. Target is usually highlighted.
    /// By default hackerman expands packages info feature nodes which can be reverted with
    /// `-P` and tries to reduce transitive dependencies to keep the tree more readable -
    /// this can be reverted with `-T`.
    ///
    /// If a crate is present in several versions you can specify version of the one you
    /// are interested in but it's optional.
    ///
    /// You can also specify which feature to look for, otherwise hackerman will be
    /// looking for all of them.
    Explain {
        #[bpaf(external(profile))]
        profile: Profile,

        /// Don't strip redundant links
        #[bpaf(short('T'), long)]
        no_transitive_opt: bool,

        /// Use package nodes instead of feature nodes
        #[bpaf(short('P'), long)]
        package_nodes: bool,

        /// Print dot file to stdout instead of spawning `xdot`
        #[bpaf(short, long)]
        stdout: bool,

        #[bpaf(positional("CRATE"))]
        krate: String,
        #[bpaf(external(feature_if))]
        feature: Option<String>,
        #[bpaf(external(version_if))]
        version: Option<Version>,
    },

    /// Lists all the duplicates in the workspace
    #[bpaf(command)]
    Dupes {
        #[bpaf(external(profile))]
        profile: Profile,
    },

    #[bpaf(command)]
    /// Make a tree out of dependencies
    ///
    ///
    ///
    ///
    /// Examples:
    ///
    /// ```sh
    /// cargo hackerman tree rand 0.8.4
    /// cargo hackerman tree serde_json preserve_order
    /// ```
    Tree {
        #[bpaf(external(profile))]
        profile: Profile,

        /// Don't strip redundant links
        #[bpaf(short('T'), long)]
        no_transitive_opt: bool,

        /// Don't include dev dependencies
        #[bpaf(short('D'), long)]
        no_dev: bool,

        /// Use package nodes instead of feature nodes
        #[bpaf(short('P'), long)]
        package_nodes: bool,

        /// Keep within the workspace
        #[bpaf(short, long)]
        workspace: bool,

        /// Print dot file to stdout instead of spawning `xdot`
        #[bpaf(short, long)]
        stdout: bool,

        #[bpaf(positional("CRATE"))]
        krate: Option<String>,
        #[bpaf(external(feature_if))]
        feature: Option<String>,
        #[bpaf(external(version_if))]
        version: Option<Version>,
    },

    #[bpaf(command("show"))]
    /// Show crate manifest, readme, repository or documentation
    ///
    ///
    ///
    ///
    /// Examples:
    ///
    /// ```sh
    /// cargo hackerman show --repository syn
    /// ```
    ShowCrate {
        #[bpaf(external(profile))]
        profile: Profile,
        #[bpaf(external(focus), fallback(Focus::Manifest))]
        focus: Focus,
        #[bpaf(positional("CRATE"))]
        krate: String,
        #[bpaf(external(version_if))]
        version: Option<Version>,
    },
}

fn feature_if() -> impl Parser<Option<String>> {
    positional::<String>("FEATURE")
        .parse::<_, _, &'static str>(|s| match Version::from_str(&s) {
            Err(_) => Ok(s),
            Ok(_) => Err("not a feature"),
        })
        .optional()
        .catch()
}

fn version_if() -> impl Parser<Option<Version>> {
    positional::<Version>("VERSION").optional().catch()
}

#[derive(Debug, Clone, Bpaf)]
/// Cargo options:
#[bpaf(custom_usage(&[("CARGO_OPTS", Style::Metavar)]))]
pub struct Profile {
    #[bpaf(argument("PATH"), fallback("Cargo.toml".into()))]
    /// Path to Cargo.toml file
    pub manifest_path: PathBuf,

    /// Require Cargo.lock and cache are up to date
    pub frozen: bool,
    /// Require Cargo.lock is up to date
    pub locked: bool,
    /// Run without accessing the network
    pub offline: bool,

    #[bpaf(external)]
    pub verbosity: (usize, Level),
}

impl Profile {
    pub fn exec(&self) -> anyhow::Result<Metadata> {
        let mut cmd = cargo_metadata::MetadataCommand::new();

        let mut extra = Vec::new();
        if self.frozen {
            extra.push(String::from("--frozen"));
        }
        if self.locked {
            extra.push(String::from("--locked"));
        }
        if self.offline {
            extra.push(String::from("--offline"));
        }
        for _ in 0..self.verbosity.0 {
            extra.push(String::from("-v"));
        }
        cmd.manifest_path(&self.manifest_path);
        cmd.other_options(extra);

        Ok(cmd.exec()?)
    }
}

#[derive(Debug, Clone, Bpaf)]
pub enum Focus {
    #[bpaf(short, long)]
    /// Show crate manifest
    Manifest,

    #[bpaf(short, long)]
    /// Show crate readme
    Readme,

    #[bpaf(short, long("doc"), long("docs"))]
    /// Open documentation URL
    Documentation,

    #[bpaf(short('R'), long, long("repo"), long("git"))]
    /// Repository
    Repository,
}

fn verbosity() -> impl Parser<(usize, Level)> {
    short('v')
        .long("verbose")
        .help("increase verbosity, can be used several times")
        .req_flag(())
        .count()
        .map(|x| {
            (
                x,
                match x {
                    0 => Level::WARN,
                    1 => Level::INFO,
                    2 => Level::DEBUG,
                    _ => Level::TRACE,
                },
            )
        })
}

#[cfg(all(test, unix))]
mod readme {

    fn write_updated(new_val: &str, path: impl AsRef<std::path::Path>) -> std::io::Result<bool> {
        use std::io::Read;
        use std::io::Seek;
        let mut file = std::fs::OpenOptions::new()
            .write(true)
            .read(true)
            .create(true)
            .open(path)?;
        let mut current_val = String::new();
        file.read_to_string(&mut current_val)?;
        if current_val != new_val {
            file.set_len(0)?;
            file.seek(std::io::SeekFrom::Start(0))?;
            std::io::Write::write_all(&mut file, new_val.as_bytes())?;
            Ok(false)
        } else {
            Ok(true)
        }
    }

    #[test]
    fn docs_are_up_to_date() {
        let usage = super::action().render_markdown("cargo hackerman");
        let readme = std::fs::read_to_string("README.tpl").unwrap();
        let docs = readme.replacen("<USAGE>", &usage, 1);
        assert!(write_updated(&docs, "README.md").unwrap());
    }
}