How to Use cargo-make for Task Running in Rust

This Rust project uses GitHub Actions and shell scripts for automation instead of cargo-make.

When scripts get out of hand

You are building a Rust project. You need to format code, run clippy, execute tests, build documentation, and maybe spin up a local database before starting the server. You could chain everything into a shell script. You could write a Python helper. Or you could reach for a task runner that understands Rust's ecosystem. This repository does not use one. The CI pipeline in .github/workflows/main.yml handles testing, linting, and building via cargo test, mdbook test, and custom scripts like ./tools/update-rustc.sh. That works fine for continuous integration. Local development often needs something lighter and more interactive.

What cargo-make actually does

cargo-make is a task runner and build tool. It reads a Makefile.toml and executes tasks in order. Think of it like make or npm scripts, but built specifically for Rust workflows. It handles dependencies between tasks, caches results, and runs everything through cargo under the hood. The tool was created to solve a specific problem: Rust projects often need dozens of one-off commands. Typing cargo fmt && cargo clippy && cargo test every time gets tedious. Wrapping it in a shell script loses cross-platform compatibility. cargo-make bridges that gap by providing a declarative configuration file that runs the same way on Linux, macOS, and Windows.

Your first Makefile.toml

Create a file named Makefile.toml in your project root. The name is mandatory. The runner looks for it automatically. Here is a minimal setup that replaces a handful of manual commands.

[tasks.test]
# Runs the test suite with a clean build directory to catch stale artifacts
command = "cargo"
args = ["test", "--all-features"]

[tasks.lint]
# Checks for warnings and style violations before running tests
command = "cargo"
args = ["clippy", "--", "-D", "warnings"]

[tasks.check]
# Chains lint and test together. cargo-make runs dependencies first.
dependencies = ["lint", "test"]

Run it with cargo make check. The tool parses the TOML, resolves the dependency graph, executes lint, waits for it to finish, then executes test. If either step fails, the runner stops immediately. Convention dictates placing Makefile.toml at the workspace root. If you have a multi-crate workspace, the runner automatically discovers it and sets the working directory to the root crate for each task.

How the runner executes your tasks

The execution model follows a strict sequence. First, the runner loads Makefile.toml and merges it with any workspace-level configuration. Next, it resolves the dependency graph for the requested task. It does not run tasks in parallel unless you explicitly configure flow control. Each task runs in a subshell. Environment variables defined in the configuration file are injected before the command starts. The runner captures stdout and stderr, applies filters if you define them, and exits with a non-zero code on failure.

You can add pre and post hooks to any task. Pre hooks run before the main command. Post hooks run after, regardless of success or failure. This pattern is useful for cleanup or logging.

[tasks.build]
# Sets up a temporary directory for build artifacts
script = '''
echo "Preparing build environment..."
'''
command = "cargo"
args = ["build", "--release"]
# Cleans up temporary files even if the build fails
script_post = '''
echo "Build complete. Cleaning up."
'''

The runner treats script and script_post as inline shell commands. It executes them in the same working directory as the main task. Convention dictates keeping inline scripts short. Long scripts belong in separate files referenced via script_extension or command. The runner also supports flow control directives like run_task, which lets you execute tasks conditionally or in loops. You can pass arguments to tasks using -- after the task name. The runner maps those arguments to placeholders in your TOML. This keeps your configuration flexible without hardcoding values.

A realistic workflow setup

Real projects need more than chained commands. They need environment setup, conditional execution, and workspace awareness. Here is a configuration that mirrors a typical library or application setup.

[config]
# Stops execution on the first failure. Prevents cascading errors.
fast_fail = true
# Skips tasks that have already run in the current session
skip_core_tasks = false

[env]
# Exposes the project root to all tasks. Useful for path resolution.
PROJECT_ROOT = { value = "${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}", force = true }

[tasks.setup]
# Installs required tools before running any development tasks
install_crate = ["cargo-expand", "cargo-audit"]

[tasks.doc]
# Builds documentation and opens it in the browser
command = "cargo"
args = ["doc", "--open", "--no-deps"]
dependencies = ["setup"]

[tasks.deploy]
# Only runs if the test suite passes. Guards against broken releases.
dependencies = ["test"]
script = '''
echo "Deploying to staging..."
# Actual deployment logic would go here
'''

The [env] section demonstrates how cargo-make handles variables. The ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY} placeholder is a built-in variable. The force = true flag ensures it cannot be overridden by the user's shell environment. This prevents accidental path collisions. The [tasks.setup] section uses the install_crate directive, which checks if a binary exists and installs it via cargo install if missing. This pattern keeps development environments consistent across machines. The runner also supports private tasks prefixed with an underscore. Those tasks are hidden from cargo make --list and are meant to be called only by other tasks. Use them to encapsulate shared logic.

Where things go wrong

Task runners introduce a layer of indirection. That layer can hide failures. If you chain too many tasks, debugging becomes a process of elimination. The runner prints output sequentially. If a pre-hook fails silently, the main task never runs. Check the exit codes. The runner propagates them, but shell scripts inside script blocks might swallow errors if you do not use set -e.

You will occasionally see cargo-make complain about missing dependencies. The error usually reads task "X" not found. This happens when you reference a task in dependencies that does not exist in the file or in the default tasks. The runner does not guess. It stops and tells you exactly which task is missing.

Another common trap is ignoring built-in cargo commands. cargo-make ships with dozens of default tasks like test, build, doc, and clean. You do not need to redefine them unless you want to change the arguments. Overriding defaults without understanding the original behavior leads to subtle breakage. Read the default task list before you start customizing.

Treat the dependency graph as a contract. If task A depends on task B, task B must succeed. If you need conditional logic, use run_task with condition blocks instead of chaining everything together. Complex conditional workflows belong in a proper build system or a CI pipeline, not in a local task runner. Keep your Makefile.toml under fifty lines. If it grows beyond that, split it into multiple files using include or switch to a different tool.

Choosing your task runner

Use cargo-make when you need cross-platform compatibility and want a TOML-based configuration that integrates tightly with cargo. Use just when you prefer a simpler syntax, faster execution, and a tool that focuses purely on command aliasing without build-step overhead. Use cargo xtask when you want to write your task runner in Rust itself, giving you full control over logic, error handling, and workspace manipulation. Use shell scripts when your workflow is simple, your team works on Unix-like systems, and you want zero dependencies. Use GitHub Actions or CI workflows when the tasks only need to run in continuous integration, like this repository does with .github/workflows/main.yml and ./tools/update-rustc.sh.

Pick the tool that matches your team's operating systems and your project's complexity. Do not overengineer a local workflow.

Where to go next