/rust-cli-boilerplate

Rust project boilerplate for CLI applications

Primary LanguageRustGNU General Public License v3.0GPL-3.0

Rust CLI Project Template

MIT/Apache 2.0 POSIX-only build tooling

A base project template for building small but reliable utilities in the Rust programming language.

NOTE: While the LICENSE file must contain my preferred choice for starting new projects (the GNU GPLv3), you may use the contents of this repository under your choice of the MIT and/or Apache 2.0 licenses.

Features

  • Uses StructOpt (with colorized --help output and "Did you mean...?" suggestions enabled) for argument parsing.
  • Uses error-chain for unified error handling.
  • Presents an app::main(opts: CliOpts) -> Result<()> function to keep your application logic cleanly separated from argument parsing and handling of terminal errors.
  • Exposes Clap's support for generating shell completions by providing a --dump-completions <shell> option.
  • Enables almost all rustc and clippy lints without making clippy mandatory.
  • A comprehensive set of just commands, easily customized via variables (eg. for cross-compilation), including install and uninstall, which also take care of shell completions and a manpage.
  • just build-dist for a 100% static i686 binary totalling roughly 252KiB (228KiB with panic="abort") in new projects
  • just install-deps to install all but two optional dependencies on Debian-family distros.
  • just install-cargo-deps to install all distro-agnostic dependencies.
  • A basic .travis.yml for use with Travis-CI and Nightli.es.
  • The fmt just command always calls the nightly version of rustfmt to ensure access to the excessive number of customization options which are gated away as unstable.

Usage

  1. Clone/download a copy of the boilerplate
  2. Run apply.py path/to/new/project
  3. Edit src/app.rs to implement your application logic

The boilerplate is currently being refactored, but you should always get a usable project skeleton by running apply.py regardless of how much the resulting skeleton may vary from revision to revision.

Supplementary Files

Metadata
LICENSE A copy of the GNU GPLv3 as my "until I've had time to think about it" license of choice. You can replace this
CONTRIBUTING.md A copy of the Developer Certificate of Origin, suitable for both this template and projects generated from it, which is the Linux kernel developers' more ideologically appropriate alternative to CLAs as a means of legally armoring themselves against bad-faith contributions
Configuration
.gitignore Ignore /target and other generated files
clippy.toml Whitelist for CamelCase names which trigger Clippy's "identifier needs backticks" lint
rustfmt.toml A custom rustfmt configuration which shows TODO/FIXME comments and attempts to make it conform to the style I'm willing to enforce at the expense of not using rustfmt if necessary.
Development Automation
apply.py Run this to generate new projects as a workaround for cargo-generate's incompatibility with justfile syntax
justfile Build/development-automation commands via just (a pure-Rust make-alike)

Justfile Reference

Variables (just --evaluate)

VariableDefault ValueDescription
CARGO_BUILD_TARGET i686-unknown-linux-musl The target for cargo commands to use and install-rustup-deps to install
build_flags --release An easy place to modify the build flags used
channel stable An easy way to override the cargo channel for just this project
features Extra cargo features to enable
build-dist
sstrip_bin sstrip Set this if you need to override it for a cross-compiling sstrip
strip_bin strip Set this to the cross-compiler's strip when cross-compiling
strip_flags --strip-unneeded Flags passed to strip_bin
upx_flags --ultra-brute Flags passed to UPX
kcachegrind
callgrind_args Extra arguments to pass to callgrind.
callgrind_out_file callgrind.out.justfile Temporary file used by just kcachegrind
kcachegrind kcachegrind Set this to override how kcachegrind is called
install and uninstall
bash_completion_dir ~/.bash_completion.d Where to install bash completions. You'll need to manually add some lines to source these files in .bashrc.
fish_completion_dir ~/.config/fish/completions Where to install fish completions. You'll probably never need to change this.
manpage_dir ~/.cargo/share/man/man1 Where to install manpages. As long as ~/.cargo/bin is in your PATH, man should automatically pick up this location.
zsh_completion_dir ~/.zsh/functions Where to install zsh completions. You'll need to add this to your fpath manually

Commands (just --list)

CommandArgumentsDescription
DEFAULT Shorthand for just test
Development
bloat args (optional) Alias for cargo bloat
check args (optional) Alias for cargo check
clean args (optional) Superset of cargo clean -v which deletes other stuff this justfile builds
doc args (optional) Run rustdoc with --document-private-items and then run cargo-deadlinks
fmt args (optional) Alias for cargo +nightly fmt -- {{args}}
fmt-check args (optional) Alias for cargo +nightly fmt -- --check {{args}} which un-bloats TODO/FIXME warnings
kcachegrind args (optional) Run a debug build under callgrind, then open the profile in KCachegrind
kcov Generate a statement coverage report in target/cov/
test Run all installed static analysis, plus cargo test
Local Builds
build Alias for cargo build
install Install the un-packed binary, shell completions, and a manpage
run args (optional) Alias for cargo run -- {{args}}
uninstall Remove any files installed by the install task (but leave any parent directories created)
Release Builds
build-dist Call build and then strip and compress the resulting binary
dist Call dist-supplemental and build-dist and copy the packed binary to dist/
dist-supplemental Build the shell completions and a manpage, and put them in dist/
Dependencies
install-apt-deps Use apt-get to install dependencies cargo can't (except kcov and sstrip)
install-cargo-deps install-rustup-deps and then cargo install tools
install-deps Run install-apt-deps and install-cargo-deps. List what remains.
install-rustup-deps Install (don't update) nightly and channel toolchains, plus CARGO_BUILD_TARGET, clippy, and rustfmt

Tips

  • Edit the DEFAULT command. That's what it's there for.

  • You can use just from any subdirectory in your project. It's like git that way.

  • just path/to/project/ (note the trailing slash) is equivalent to (cd path/to/project; just)

  • just path/to/project/command is equivalent to (cd path/to/project; just command)

  • just install-cargo-deps will install cargo-edit so you can use cargo add, cargo rm, and cargo upgrade to easily manage your dependencies.

  • The simplest way to activate the bash completion installed by just install is to add this to your .bashrc:

    for script in ~/.bash_completion.d/*; do
      . "$script"
    done
  • The simplest way to activate the zsh completion installed by just install is to add this to your .zshrc:

    fpath=(~/.zsh/functions(:A) $fpath)
  • Only use Clap/StructOpt validators for references like filesystem paths (as opposed to self-contained data like set sizes) as a way to bail out early on bad data, not as your only check of validity. See this blog post for more.

Build Behaviour

In order to be as suitable as possible for building self-contained, high-reliability replacements for shell scripts, the following build options are defined:

If built via cargo build:

  1. Backtrace support will be disabled in error-chain unless explicitly built with the backtrace feature. (This began as a workaround to unbreak cross-compiling to musl-libc and ARM after backtrace-rs 0.1.6 broke it, but it also makes sense to opt out of it if you're using panic="abort" to save space)

If built via cargo build --release:

  1. Unless otherwise noted, all optimizations listed above.
  2. Link-time optimization will be enabled (lto = true)
  3. The binary will be built with opt-level = "z" to further reduce file size.
  4. Optionally (uncomment a line in Cargo.toml) panic via abort rather than unwinding to allow backtrace code to be pruned away by dead code optimization.

If built via just build-dist:

  1. Unless otherwise noted, all optimizations listed above.
  2. The binary will be statically linked against musl-libc for maximum portability.
  3. The binary will be stripped with --strip-unneeded and then with sstrip (a more aggressive companion used in embedded development) to produce the smallest possible pre-compression size.
  4. The binary will be compressed via upx --ultra-brute. In my experience, this makes a file about 1/3rd the size of the input.

NOTE: --strip-unneeded removes all symbols that readelf --syms sees from the just build output, so it's not different from --strip-all in this case, but it's a good idea to get in the habit of using the safe option that's smart enough to just Do What I Mean™.

If built by just dist:

  1. A packed binary will be built via build-dist and copied into dist/
  2. Shell completion files and a manpage will also be built and saved into dist/

Dependencies

In order to use the full functionality offered by this boilerplate, the following dependencies must be installed:

  • just bloat:
  • just build-dist:
    • The toolchain specified by the channel variable.
    • The target specified by the target variable.
    • strip (Included with binutils)
    • sstrip (optional)
    • upx (optional, sudo apt-get install upx)
  • just fmt and just fmt-check:
    • A nightly Rust toolchain
    • (rustup toolchain install nightly)
    • rustfmt for the nightly toolchain (rustup component add rustfmt --toolchain nightly)
  • just dist-supplemental:
    • help2man (sudo apt-get install help2man)
  • just kcachegrind:
  • just kcov:
  • just test:

Dependency Installation

  • Debian/Ubuntu/Mint:

      export PATH="$HOME/.cargo/bin:$PATH"
      cargo install just
      just install-deps
    
      # ...and now manually install the following optional tools:
      #  - sstrip (from ELFkickers)
      #  - kcov (version 31 or higher with --verify support)
    
  • Other distros:

      export PATH="$HOME/.cargo/bin:$PATH"
      cargo install just
      just install-cargo-deps
    
      # ...and now manually install the following optional tools:
      #  - help2man
      #  - kcachegrind
      #  - kcov (version 31 or higher with --verify support)
      #  - strip (from binutils)
      #  - sstrip (from ELFkickers)
      #  - upx
      #  - valgrind
    

TODO

  • Get a feel for the workflow surrounding building a project with Failure and decide whether to rebase this template on top of it.
  • Investigate how flexible QuiCLI and its dependency on env_logger are and whether it'd be useful to rebase on it or whether I'd just be reinventing most of it anyway to force the exact look and feel I achieved with stderrlog. (eg. The Verbosity struct doesn't implement "-v and -q are mirrors of each other" and I'm rather fond of stderrlog's approach to timestamp toggling.)
    • What effect does quicli have on the final binary size? (not a huge concern)
  • Investigate why cargo-cov isn't hiding the components of the rust standard library and whether it can be induced to generate coverage despite some tests failing. If so, add a command for it.
  • Figure out whether StructOpt or Clap is to blame for doubling the leading newline when about is specified via the doc comment and then report the bug.
  • Read the callgrind docs and figure out how to exclude the Rust standard library from what KCacheGrind displays.
    • I may need to filter the output. [1]
    • Figure out how to add a just task for a faster but less precise profiler like gprof [1] [2], OProfile [1], or perf [1] to make it easy to leverage the various trade-offs. (And make sure to provide convenient access to flame graphs and at least one perf inspector GUI or TUI.)
    • Include a reference to this blog post on how profilers can can mislead in different ways and probably also this too.
    • Look into options for making it as easy as possible to optimize and regression-test runtime performance. [1] [2] [3] [4]
  • Test and enhance .travis.yml
  • Add a run-memstats Just task which swaps in jemalloc and sets MALLOC_CONF=stats_print:true
  • Investigate commit hooks [1] [2] [3]
  • Once I've cleared out these TODOs, consider using this space for a reminder list of best practices for avoiding "higher-level footguns" noted in my pile of assorted advice. (Things like "If you can find a way to not need path manipulation beyond 'pass this opaque token around', then you can eliminate entire classes of bugs")
  • At least list a snip of example code for something like RustyLine as the suggested way to do simple user prompting.
  • Gather my custom clap validators into a crate, add some more, and have this depend on it:
    • Self-Contained data:
      • Boolean is 1/y/yes/t/true or 0/n/no/f/false (case-insensitive, include a utility function for actual parsing)
      • Integers:
        • Can be parsed as a decimal integer > 0 (eg. number of volumes)
        • Can be parsed as a decimal integer >= 0 (eg. number of bytes)
        • Number of bytes, with optional SI mebi- unit suffix (eg. 16m, including optional b, case-insensitive)
      • Floats:
        • Can be parsed as a float in the range 0.0 <= x <= 1.0
    • Invalidatable/Referential data:
      • Input files:
        • File exists and is readable
        • Directory exists and is browsable (+rX)
        • Path is a readable file or browsable directory (ie. read or recurse)
      • Output files:
        • Integers:
          • Augmented "number of bytes, with optional SI mebi- unit suffix" validator with upper limit for producing files representable by ISO9660/FAT32 filesystems on removable media. (2GiB, since some implementations use 32-bit signed offsets)
        • Strings:
          • Is valid FAT32-safe filename/prefix (path separators disallowed)
        • Paths:
          • File path is probably FAT32 writable
            • If file exists, access() says it's probably writable
            • If file does not exist, name is FAT32-valid and within a probably-writable directory.
          • File path is probably FAT32 writable, with mkdir -p
            • Nonexistent path components are FAT32-valid
            • Closest existing ancestor is a probably-writable directory
          • Directory exists and is probably writable
            • "probably writable" is tested via access() and will need portability shimming.
      • Network I/O:
        • Integers:
          • Successfully parses into a valid listening TCP/UDP port number (0-65535, I think)
          • Successfully parses into a valid, non-root, listening TCP/UDP port number (0 or 1024-65535, I think)
          • Successfully parses into a valid connecting TCP/UDP port number (1-65535, I think)
        • Strings:
          • Successfully parses into a SocketAddr (IP+port, may perform DNS lookup?)
          • Successfully parses into an IpAddr (may perform DNS lookup?)
        • URLs: