Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration

RSConstruct is configured via an rsconstruct.toml file in the project root. An optional rsconstruct.local.toml overlay, when present, is merged over the main file at load time — see Local overlay. An optional user-level file can set [build] defaults underneath both and choose the columns the CLI’s tables show — see User config.

Full reference

[build]
parallel = 1          # Number of parallel jobs (1 = sequential, 0 = auto-detect CPU cores)
                      # Also settable via RSCONSTRUCT_THREADS env var (CLI -j takes precedence)
batch_size = 0        # Max files per batch for batch-capable processors (0 = no limit)
output_dir = "out"    # Global output directory prefix for generator processors

# Declare processors by adding [processor.TYPE.NAME] sections.
# Only declared processors run — no processors are enabled by default.
# Use `rsconstruct smart auto` to auto-detect and add relevant processors.

[processor.checker.ruff]
# args = []

[processor.checker.pylint]
# args = ["--disable=C0114"]

[processor.generator.cc_single_file]
# cc = "gcc"
# cflags = ["-Wall", "-O2"]

[vars]
my_excludes = ["/vendor/", "/third_party/"]  # Define variables for reuse with ${var_name}

[cache]
restore_method = "auto"  # auto (default: copy in CI, hardlink otherwise), hardlink, or copy
compression = false      # Compress cached objects with zstd (requires restore_method = "copy")
remote = "s3://my-bucket/rsconstruct-cache"  # Optional: remote cache URL
remote_push = true       # Push local builds to remote (default: true)
remote_pull = true       # Pull from remote cache on cache miss (default: true)
mtime_check = true       # Use mtime pre-check to skip unchanged file checksums (default: true)
webcache_ttl_secs = 604800  # How long a fetched HTTP response stays fresh (default: 7 days)

[analyzer]
auto_detect = true
enabled = ["cpp", "python"]

[graph]
viewer = "google-chrome"  # Command to open graph files (default: platform-specific)

[completions]
shells = ["bash"]

[dependencies]
pip = ["pyyaml", "jinja2"]    # Python packages
npm = ["eslint", "prettier"]  # Node.js packages
gem = ["mdl"]                 # Ruby gems
cargo = ["cargo-deny"]        # Crates from crates.io (cargo install --locked)
system = ["pandoc", "graphviz"]  # System packages (checked but not auto-installed)

[pages]
dir = "out/web"  # Directory published to GitHub Pages (omit the section entirely for non-Pages repos)

[command.symlink_install]
sources = ["dotfiles"]  # Folders whose files `rsconstruct symlink-install` links...
targets = ["~/"]        # ...into these folders, pairwise (same length as sources)

Per-processor configuration is documented on each processor’s page under Processors.

Processor instances

Processors are declared by adding a [processor.TYPE.NAME] section to rsconstruct.toml. An empty section enables the processor with default settings:

[processor.checker.pylint]

Customize with config fields:

[processor.checker.pylint]
args = ["--disable=C0114,C0116"]
src_dirs = ["src"]

Remove the section to disable the processor.

Multiple instances

Run the same processor multiple times with different configurations by adding named sub-sections:

[processor.checker.pylint.core]
src_dirs = ["src/core"]
args = ["--disable=C0114"]

[processor.checker.pylint.tests]
src_dirs = ["tests"]
args = ["--disable=C0114,C0116"]

Each instance runs independently with its own config and cache.

You cannot mix single-instance and multi-instance formats for the same processor — use either [processor.checker.pylint] or [processor.checker.pylint.NAME], not both.

Processor names

A processor is named by its full path, processor.<type>.<name>, the same path its source file has (src/processor/checker/ruff.rs is processor.checker.ruff). The type is one of checker, generator, creator, explicit, mass_generator and lua; the name is unique only within its type, which is why the config-driven processors share one name: processor.creator.generic, processor.generator.generic, processor.explicit.generic, processor.mass_generator.generic. The section header is the name: [processor.checker.ruff].

rsconstruct processor list prints every processor’s full name; rsconstruct processor list --type creator narrows it to one type.

Instance naming

A single instance declared as [processor.checker.pylint] has the instance name processor.checker.pylint. Named instances declared as [processor.checker.pylint.core] and [processor.checker.pylint.tests] have instance names processor.checker.pylint.core and processor.checker.pylint.tests.

The instance name is used everywhere a processor is identified:

  • Build output and progress: [processor.checker.pylint.core] src/core/main.py
  • Error messages: Error: [processor.checker.pylint.tests] tests/test_foo.py: ...
  • Build statistics: each instance reports its own file counts and durations
  • Cache keys: instances have separate caches, so changing one config does not invalidate the other
  • Output directories: generator processors default to out/{instance_name} — out/processor.generator.tera for a single tera instance, out/processor.generator.marp.slides and out/processor.generator.marp.docs for two marp instances — so outputs never collide
  • The --processor filter: use the full instance name, e.g., rsconstruct build -p processor.checker.pylint.core
  • processor config, processor files, --iset: rsconstruct processor config processor.checker.pylint.core, --iset processor.checker.pylint.core.max_jobs=2

Migrating from short names

Until rsconstruct 0.9.x a processor was named by its bare name ([processor.ruff], -p ruff, out/tera). The full-name scheme is a one-shot, mechanical rewrite of every rsconstruct.toml:

BeforeAfter
[processor.ruff][processor.checker.ruff]
[processor.pylint.core][processor.checker.pylint.core]
[processor.creator.venv][processor.creator.generic.venv]
[processor.explicit][processor.explicit.generic]
src_dirs = ["out/tera"]src_dirs = ["out/processor.generator.tera"]
rsconstruct build -p ruffrsconstruct build -p processor.checker.ruff

scripts/migrate_processor_names.py in the rsconstruct repository performs the rewrite for one or more repositories (python3 scripts/migrate_processor_names.py ~/git/*/rsconstruct.toml): it renames every section header, rewrites out/<name> references to processors whose default output directory moved, and reports any other file in the repository that still mentions an old out/<name> path, so Makefiles and CI that point into out/ can be fixed by hand. It asks the installed rsconstruct processor list --json which type each name has, so it needs a rsconstruct that already speaks full names. An old-style header is a config error with a hint ([processor.ruff]: unknown processor type 'ruff' … did you mean [processor.checker.ruff]?), so a missed file fails loudly rather than silently building nothing.

Every cache entry is keyed by the processor name, so the first build after migrating rebuilds everything once.

Auto-detection

Run rsconstruct smart auto to scan the project and automatically add [processor.TYPE.NAME] sections for all processors whose files are detected and whose tools are installed. It does not remove existing sections.

Variable substitution

Define variables in a [vars] section and reference them using ${var_name} syntax:

[vars]
kernel_excludes = ["/kernel/", "/kernel_standalone/", "/examples_standalone/"]

[processor.checker.cppcheck]
src_exclude_dirs = "${kernel_excludes}"

[processor.generator.cc_single_file]
src_exclude_dirs = "${kernel_excludes}"

Variables are substituted before TOML parsing. The "${var_name}" (including quotes) is replaced with the TOML-serialized value, preserving types (arrays stay arrays, strings stay strings). A reference must constitute the entire quoted value — an embedded reference like "${base}/src" cannot be substituted and is a config error, as is any undefined variable reference.

Local overlay (rsconstruct.local.toml)

If a file named rsconstruct.local.toml exists next to rsconstruct.toml, it is loaded automatically and deep-merged over the main config. This lets many repos share one canonical, identical rsconstruct.toml while keeping repo-specific configuration — [dependencies] lists, [processor.explicit.*] generators, exclude lists, one-off workarounds — in the local file.

Merge semantics:

  • Tables merge recursively. A local [processor.checker.mypy] with only batch = false adds that one key to the main file’s mypy section; the rest of the section is untouched.
  • Arrays and scalars replace wholesale. If the local file sets src_dirs, that exact list is used — values are never concatenated, so the local file can also remove entries by setting a shorter list.
  • Local-only sections are added. The overlay can declare whole processors, analyzers, or global sections the main file doesn’t have.

Rules and caveats:

  • The overlay only extends a main config: rsconstruct.local.toml without an rsconstruct.toml is an error.
  • [vars] substitution is per-file — each file’s ${...} references resolve against its own [vars] section only.
  • The merged result is validated as one config, so unknown fields or type errors in the local file are reported the same way as in the main file.
  • rsconstruct processor config <iname> shows the merged values.
  • In watch mode, both files are watched for changes.

This supports a shared-config pattern: one rsconstruct.toml distributed across many repos plus a small optional rsconstruct.local.toml per repo for what’s genuinely repo-specific. Note that every src_dirs entry must exist in every repo that carries it (see Missing src_dirs and src_files entries); a shared file can no longer list directories only some repos have, and [build] allow_missing_src_dirs exists for a project that still wants that.

User config (~/.config/rsconstruct/config.toml)

A per-user file, read from $XDG_CONFIG_HOME/rsconstruct/config.toml (or ~/.config/rsconstruct/config.toml when XDG_CONFIG_HOME is unset), sets [build] defaults for every project you build on this machine, and under [tables] chooses which columns the CLI’s tables show. For [build] the precedence is, lowest first:

  1. built-in defaults
  2. the user config
  3. rsconstruct.toml
  4. rsconstruct.local.toml

Keys merge one by one, so a repo that sets a [build] key overrides the user value for that key only. Example:

# ~/.config/rsconstruct/config.toml
[build]
reject_dot_src_dirs = true

Rules:

  • Only [build] and [tables] are allowed. A [processor.*], [analyzer.*] or any other table in this file is a config error naming the offending section. Anything that changes what a repo scans or builds belongs in the repo’s own files, where every machine and CI see the same thing; a machine-wide file that could add processors would make the same repo build differently on two machines. [tables] passes because it changes nothing about the build, only how the CLI prints its tables.
  • CI never reads it. Continuous integration checks out the repo and nothing else, so a switch that must hold in CI has to be set in rsconstruct.toml. The user file is a local safety net and a way to try a policy across all your projects before committing it to each of them.
  • It applies even in a directory without rsconstruct.toml, for the commands that run without a config.
  • Watch mode does not watch this file; restart the watcher after editing it.
  • rsconstruct toml files prints the resolved path of this file, together with the rest of the chain, and whether each one exists.

[tables]

Every table rsconstruct prints has a name and a fixed set of columns. By default a table shows all of them except a few that are hidden unless --verbose asks for them. An entry under [tables] replaces that default for one table: the value is the list of columns to show, in the order to show them, spelled exactly as the column headers are.

# ~/.config/rsconstruct/config.toml
[tables]
# processor list: just the names and whether each one batches.
processor_list = ["Name", "Batch"]
# tool list: always show how to install a tool, without --verbose.
tool_list = ["Tool", "Status", "Install"]
# hook: the Lua function names first, descriptions last.
hook_list = ["Function", "Hook", "Description"]

rsconstruct --fields <command> prints a table’s name, the command that prints it and its column names, with no data; rsconstruct --all <command> shows every column of every table, whatever this section says. A key that names no table, or a column that the table does not have, is a config error listing the valid names. The two tables whose columns depend on the data (product_status, tag_matrix) are checked when they are printed; an unknown column there is a warning and is skipped.

The tables, their commands and their columns; a column marked (verbose) is hidden by default:

TableCommandColumns
analyzer_defconfigrsconstruct analyzer defconfigField, Type, Default
analyzer_listrsconstruct analyzer listName, Native, Description (verbose)
analyzer_statsrsconstruct analyzer stats, analyzer buildAnalyzer, Files, Dependencies
analyzer_usedrsconstruct analyzer usedName, Detected, Description (verbose)
build_timingsrsconstruct build --timingsPhase, Duration
error_listrsconstruct errorCode, Name, Description (verbose)
hook_listrsconstruct hookHook, Function (verbose), Location (verbose), Description
info_sourcersconstruct info sourceExtension, Count
processor_configrsconstruct processor configField, Value
processor_fieldsrsconstruct processor defconfig, processor infoField, Type, Default, Required, Checksum, Description (verbose)
processor_listrsconstruct processor listName, Type, Native, Rust, Batch, Cache, Rust alternative, Description (verbose)
processor_recommendrsconstruct processor recommendExtension / File, Processor, Also, Reason
processor_searchrsconstruct processor searchName, Type, Description, Keywords
processor_typesrsconstruct processor typesType, Batch, Description, Batching (verbose)
processor_usedrsconstruct processor usedName, Type, Detected, Description (verbose)
product_statusrsconstruct status, build --dry-runProcessor, one column per product state (up-to-date, restorable, stale, new for status; skip, restore, build, build-new for build --dry-run), native, rust
slocrsconstruct slocLanguage, Files, Blank, Comment, Code
status_breakdownrsconstruct status --breakdownProcessor, Files, Breakdown
tag_countrsconstruct tag countCount, Tag
tag_coveragersconstruct tag coverageCategory, Files, Coverage
tag_matrixrsconstruct tag matrixFile, then one column per tag category
tag_suggestrsconstruct tag suggestTag, Score
tool_listrsconstruct tool listTool, Status, Runtime, Install (verbose)
tool_runtimesrsconstruct tool statsRuntime, Installed
tool_statsrsconstruct tool statsTool, Status, Processors, Install
webcache_listrsconstruct webcache listURL, Size, Age, State

Section details

[build]

KeyTypeDefaultDescription
parallelinteger1Number of parallel jobs. 1 = sequential, 0 = auto-detect CPU cores. Can also be set via the RSCONSTRUCT_THREADS environment variable (CLI -j takes precedence).
batch_sizeinteger0Maximum files per batch for batch-capable processors. 0 = no limit (all files in one batch). To disable batching, pass --batch-size -1 on the CLI or set batch = false on individual processors.
output_dirstring"out"Global output directory prefix. Processor output_dir defaults that start with out/ are remapped to use this prefix (e.g., setting "build" changes out/processor.generator.marp to build/marp). Individual processors can still override their output_dir explicitly.
max_discovery_passesinteger10Maximum fixed-point discovery passes. Discovery repeats while processors keep adding products from each other’s declared outputs; a config still adding products at the cap fails the build (naming the processors involved) instead of silently truncating the graph.
hash_tool_versionsbooleantrueMix the identity of each processor’s external tools into its products’ cache keys, so upgrading a tool invalidates what that tool produced. Tools are identified by a content hash of the resolved binary (no --version subprocess; the mtime cache makes repeat builds a stat), or by the pinned version_output when rsconstruct tool lock has written a .tools.versions file. Set to false to leave tool identity out of cache keys entirely.
warn_symlinksbooleanfalseWarn about every symlink skipped during the file-index walk. The walker never follows symlinks, so a symlinked source (or a symlinked directory of sources) is absent from the index — never checked, never built. Set to true to make that gap visible. Off by default because projects that deliberately keep symlinks around (vendored trees, dotfile farms) would drown in warnings.
command_timeout_secsinteger0Wall-clock limit in seconds for every external command a processor runs; a command still running at the limit is killed and its product fails with Command timed out after Ns and was killed. 0 means no limit. Off by default, on purpose: a tool that hangs is a bug in the tool, its input, or the environment, and the fix is to find that cause — not to cut the tool off. Set this only where a build must not be allowed to sit forever (an unattended runner) and a loud kill beats a silent hang. A processor with its own timeout (marp’s timeout_secs) keeps it; the explicit value wins.
allow_missing_dep_autobooleanfalseSkip a dep_auto entry you listed when the file does not exist, as every entry used to be skipped. By default such an entry is a config error naming the processor, the file and the config line: a listed file that is missing is a typo or a stale copy of a shared config, and either way a dependency that silently tracks nothing. Processor defaults (.pylintrc, ruff.toml, …) are optional by nature and are never checked.
allow_missing_src_dirsbooleanfalseSkip a src_dirs entry that names a directory which does not exist, as every entry used to be skipped. By default such an entry is a config error naming the processor and the entry: a directory listed in the config that is not there is a typo, a stanza left behind when the directory moved, or a copy of another repo’s config, and in every case a processor that checks nothing while the build stays green. A directory an upstream processor declares as its output is never reported, whatever this is set to. This switch covers directories only: a missing src_files entry is always an error.
reject_dot_src_dirsbooleanfalseMake a src_dirs entry of "." a config error. "." and "" both mean the whole project tree, recursively; written as "." it reads like “the root directory” and hides that the stanza sweeps every subdirectory too. Turn this on in a project whose stanzas are meant to name the directories they cover: the error names the stanza and the config line, and the fix is to list the real directories, or to write "" if sweeping the tree is intended. Off by default because "." is not wrong, only easy to misread. Disabled stanzas are not checked.
worker_stack_mibinteger256Stack size, in MiB, of each build worker thread. The native checkers parse arbitrary files with recursive parsers; on a small stack a deeply nested file (thousands of ( in Lua, of <div> in HTML) overflows it, which aborts the whole process. Only touched pages are committed, so the reservation costs address space, not memory. A panic inside a processor fails that product only.

The output_dir prefix is purely a layout choice — rsconstruct clean outputs does not special-case it. Cleanup is driven by per-product outputs and output_dirs declarations, then a generic empty-directory sweep walks parents bottom-up. See Clean behavior and rsconstruct clean for details.

[processor.TYPE.NAME]

Each [processor.TYPE.NAME] section declares a processor instance. The header is the processor’s full name: TYPE is its type (checker, generator, creator, explicit, or mass_generator) and NAME the processor within that type ([processor.checker.ruff], [processor.generator.cc_single_file]). A fourth segment names an instance ([processor.checker.pylint.core]).

Common fields available to all processors:

KeyTypeDefaultDescription
enabledbooleantrueSet to false to disable this processor without removing the stanza. Accepted on every processor. A disabled processor is skipped during discovery, so it produces no products; smart remove-no-file-processors ignores disabled stanzas rather than reporting them as matching no files.
argsarray of strings[]Extra command-line arguments passed to the tool.
dep_inputsarray of strings[]Additional input files that trigger rebuild when changed.
dep_autoarray of stringsvariesConfig files added as inputs. The processor’s default list (e.g. .pylintrc) is skip-if-absent; a list you write here replaces it, and every entry in it must exist unless [build] allow_missing_dep_auto is set.
required_toolsarray of strings[]Extra tools this processor needs beyond command. Normally command is the tool, so this is empty. Name the real tool here when command is a wrapper script that shells out to it — otherwise that tool is invisible to rsconstruct tool install and to version locking, and a missing tool surfaces only as a failure inside the wrapper. Each name must have a registry entry (rsconstruct tool list).
batchbooleantrueWhether to batch multiple files into a single tool invocation. A batching checker waits for all of its files to be ready and runs once over them; --batch-size on the command line limits or disables batching build-wide. For external tools, a batch failure marks all products in the chunk as failed. Internal processors (i-prefixed) return per-file results, so partial failure is handled correctly.
output_depends_on_input_namebooleanfalse (true for ruff, pylint and mypy)Set to true when the processor’s result depends on the input files’ paths and not only on their content: its output names the input (// Generated from foo.c), or it is a checker with per-path rules (ruff’s per-file-ignores, mypy’s module resolution). The cache normally keys a product by input content, so a renamed file with the same content, or an identical file at another path, reuses the cached result; with this set, the input paths are part of the key and such a file is rebuilt. Unchanged files are still skipped and restored from the cache as usual. Accepted on every processor.
max_jobsintegernoneMaximum concurrent jobs for this processor. When set, limits how many instances of this processor run in parallel, regardless of the global -j setting. Useful for heavyweight processors (e.g., marp spawns Chromium). Omit to use the global parallelism.
src_dirsarray of strings[]Directories to scan for source files. Every processor defaults to [], which scans nothing — no processor guesses a directory, so one declared without src_dirs matches no files and simply builds nothing. A default like ["src"] would be a guess that silently matches the wrong directory in a project laid out differently; naming the directory is the user’s call. To scan the whole project deliberately, use src_dirs = [""] — an empty string means the project root and walks everything beneath it, including node_modules/, .venv/ and target/ unless excluded. "." means exactly the same thing, but reads as if it named only the root directory; [build] reject_dot_src_dirs = true forbids that spelling. src_files is an alternative to src_dirs for listing exact paths. Every entry must name a directory that exists; an entry that does not is a config error (exit code 2) naming the processor and the entry — see Missing src_dirs and src_files entries below. The one exception is a directory an upstream processor declares as its output: it does not exist before that processor runs, and is accepted. Use rsconstruct processor defconfig <name> to see a processor’s defaults.
src_extensionsarray of stringsvariesFile extensions to match.
src_exclude_dirsarray of stringsvariesDirectory path segments to exclude from scanning.
src_exclude_filesarray of strings[]File names to exclude.
src_exclude_pathsarray of strings[]Paths (relative to project root) to exclude.
src_filesarray of strings[]Exact paths to match, relative to the project root. On its own it is an allowlist: exactly the named files are matched and nothing else (no directory scan, no extension filter). Together with src_dirs, the named files are added to what the directories yield. Every entry must name a file that exists, or a path an upstream processor declares as its output; anything else is a config error (exit code 2), and unlike a missing directory it is never forgiven — allow_missing_src_dirs does not apply to files. See Missing src_dirs and src_files entries.

Processor-specific fields are documented on each processor’s page under Processors.

[cache]

KeyTypeDefaultDescription
restore_methodstring"auto"How to restore cached outputs. "auto" (default) uses "copy" in CI environments (CI=true) and "hardlink" otherwise. "hardlink" is faster but requires same filesystem; "copy" works everywhere.
compressionbooleanfalseCompress cached objects with zstd. Incompatible with restore_method = "hardlink" — requires "copy".
remotestringnoneRemote cache URL. See Remote Caching.
remote_pushbooleantruePush locally built artifacts to remote cache.
remote_pullbooleantruePull from remote cache on local cache miss.
mtime_checkbooleantruePersist file checksums across builds using an mtime database. Set to false in CI/CD environments where the cache won’t survive the build and the write overhead isn’t worth it. Can also be disabled via --no-mtime-cache flag. See Checksum Cache.
webcache_ttl_secsinteger604800 (7 days)How long a cached HTTP response (remote JSON schemas fetched by iyamlschema) stays fresh. An entry older than this is re-fetched. Set to 0 to disable the web cache entirely and always re-fetch. Expired entries are reclaimed by rsconstruct cache trim.

rsconstruct cache trim reclaims three kinds of dead weight: unreferenced objects in the store, mtime-database rows for files that no longer exist, and expired web-cache entries. Without it these grow with the history of the project rather than its current size.

[analyzer]

KeyTypeDefaultDescription
auto_detectbooleantrueWhen true, only run enabled analyzers that auto-detect relevant files.
enabledarray of strings["cpp", "python"]List of dependency analyzers to enable.

[graph]

KeyTypeDefaultDescription
viewerstringplatform-specificCommand to open graph files

[completions]

KeyTypeDefaultDescription
shellsarray["bash"]Shells to generate completions for

Missing src_dirs and src_files entries

A src_dirs or src_files entry is a claim about where the project keeps its sources. When the directory or file it names is not there, the build fails at discovery with exit code 2 (EXIT_CONFIG_ERROR) and lists every offending entry:

Invalid config:
  [processor.checker.pylint] src_dirs entry 'scripts' does not exist or is not a directory
  [processor.checker.luacheck] src_dirs entry 'config' does not exist or is not a directory
  [processor.checker.taplo] src_files entry 'pyproject.toml' does not exist or is not a file
Every src_dirs entry must name a directory that exists and every src_files
entry a file that exists (or a path an upstream processor declares as its
output) — fix the path or remove the entry; [build] allow_missing_src_dirs =
true skips absent directories as before, never absent files

Directories used to be a silent skip, visible only under --phases, and files were never checked at all. That let a stanza whose directory had moved, one copied from another repo, or one with a typo in a file name keep the build green while checking nothing at all — the worst kind of failure, because the green tick says the check ran. The fix is always in the config: point the entry at the directory or file that actually exists, or delete the stanza if the files are gone.

What is not reported:

  • "" and ".", which mean the project root and always exist.
  • A directory or file an upstream processor declares as its output (for example a linter whose src_dirs is a generator’s output_dir, or whose src_files names one of its outputs). It does not exist before that processor has run; discovery sees the declared outputs as virtual files and accepts the entry.
  • Entries of a stanza with enabled = false.
  • Anything during rsconstruct clean and rsconstruct smart remove-no-file-processors: clean must still be able to remove the outputs of a build whose source directory has since gone, and the repair command exists to delete the very stanzas this check rejects. Both report the entries under --phases instead.

[build] allow_missing_src_dirs = true restores the old skip for directories in a project that needs it; the skipped entries are then listed under --phases. It never covers src_files: a directory can at least plausibly be empty, a named file that is not there is simply wrong. Prefer fixing the entry: the switch hides exactly the misconfiguration this check exists to catch.

[dependencies]

Declare project dependencies by package manager. Used by rsconstruct doctor to verify availability and rsconstruct tool install-deps to install missing packages.

KeyTypeDefaultDescription
piparray of strings[]Python packages to install via pip install. Supports version specifiers (e.g., "ruff>=0.4").
pip_sourcestring"uv-lock"Where the Python package set comes from: "uv-lock" installs the pinned closure from uv.lock; "pyproject" resolves the names pyproject.toml declares at install time.
python_installerstring"uv"Which tool installs the Python package set: "uv" runs uv pip install --python python3; "pip" runs pip install. See below for why the two differ on the same lock.
npmarray of strings[]Node.js packages to install globally via npm install -g, on top of the project’s own package.json set.
npm_sourcestring"package-lock"Where the project’s Node.js package set comes from: "package-lock" installs the closure package-lock.json pins with npm ci; "package-json" resolves package.json’s ranges with npm install.
gemarray of strings[]Ruby gems to install via gem install.
cargoarray of strings[]Crates to install from crates.io via cargo install --locked, by crate name (e.g. "cargo-deny", "cargo-nextest", "mdbook"). Bare names only; the version floats. See below.
systemarray of strings[]System packages installed via the detected package manager (apt-get, dnf, pacman, or brew).

Python dependencies come from uv.lock by default

In the default pip_source = "uv-lock" mode, install-deps and doctor take the Python package set from uv.lock: the exact pinned closure — including transitive dependencies — that uv lock resolved, so CI installs are reproducible instead of floating to whatever pip resolves that day. Only registry packages are read from the lock; the project’s own entry is skipped, and any other source kind (git, path) is an error. A project whose pyproject.toml declares Python dependencies but has no uv.lock is an error: run uv lock, or opt out per repo with pip_source = "pyproject".

In pip_source = "pyproject" mode the set is instead the dependency names pyproject.toml declares, resolved by pip at install time:

  • [project].dependencies
  • every list in [project.optional-dependencies]
  • every PEP 735 [dependency-groups] list (entries that are {include-group = "..."} tables are skipped, since every group is read anyway)

In both modes [dependencies].pip entries merge in front and the result is deduplicated by PEP 503-normalized distribution name plus extras, so a [dependencies].pip entry wins over a lock or pyproject one. Use [dependencies].pip for repos without a pyproject.toml and for packages CI needs that the project itself does not declare.

uv installs the Python set by default

With the default python_installer = "uv", install-deps hands the Python set to uv pip install --python python3 — targeting whichever interpreter python3 on PATH resolves to, the same environment a bare pip install would touch. Installing a uv-produced lock with uv matters for two reasons:

  • Requires-Python upper bounds. uv deliberately ignores a package’s declared upper Requires-Python bound when resolving (upper bounds are almost always stale metadata), so uv lock can pin a version whose bound excludes the running interpreter. uv installs that pin; pip enforces the bound and refuses it with “No matching distribution found”.
  • Environment markers. In uv-lock mode the pins come from uv export (run with --frozen, so an out-of-sync lock is an error, not a silent re-resolve), which annotates platform-conditional packages with markers like pywin32==312 ; sys_platform == 'win32'. uv skips a pin whose marker does not match the current platform, where the flattened lock closure would ask for another platform’s package and fail.

uv also audits the whole installed set itself in one pass, so uv mode skips the per-package pip show preflight probing.

When uv is not on PATH and there is Python work to do, install-deps bootstraps it with pip install uv first — CI runner images ship pip but not necessarily uv, and the consuming repo’s workflow should not need to know which installer rsconstruct uses.

Set python_installer = "pip" to keep the pre-uv behavior: per-package pip show probing followed by pip install of the flattened set.

Node.js dependencies come from package-lock.json by default

The Node side works the same way. A project’s Node.js packages are declared in its package.json (dependencies, devDependencies and optionalDependencies — the three sections npm ci installs) and pinned by the package-lock.json beside it. In the default npm_source = "package-lock" mode, install-deps runs npm ci in the project root, which installs exactly the lock’s closure into the project’s own node_modules/ and fails if the lock is out of sync with the manifest instead of silently re-resolving. A package.json that declares packages but has no package-lock.json is an error: run npm install --package-lock-only, or opt out per repo with npm_source = "package-json", which runs npm install and lets the versions float.

The install is skipped when every declared package is already present in node_modules/ at the version the lock pins — npm ci wipes node_modules/ before installing, so a needless run is not free. doctor reports each declared package the same way: installed means present in the project’s node_modules/, not somewhere global.

To make the installed executables resolvable, every rsconstruct invocation prepends the project’s node_modules/.bin, when it exists, to its own PATH at startup (the same mechanism that appends the user gem bin dirs, see rsconstruct tool in the commands reference). Prepended, so the version the lock pins wins over a global install of the same tool — processors keep invoking stylelint or mmdc plainly, and tool install sees the locked copy and does not install a global one. This is what npm run does for scripts.

[dependencies].npm remains for a package that must be global; it is installed with npm install -g alongside, and is not the place for a project’s own tools.

Rust crates are a list, not tools

A cargo subcommand or other Rust-built tool a build runs (cargo-deny, cargo-nextest, mdbook) is a crate on crates.io, and crates.io is a package registry like PyPI or npm. So these are declared as a plain list under cargo, by crate name, and are not entries in the tool registry. install-deps installs the missing ones in one cargo install --locked call: --locked builds each crate with the Cargo.lock it was published with, so a given crate version yields the same binary everywhere, while the version itself floats to the latest release — the manifest carries bare names, as everywhere else in the fleet.

Presence is judged by cargo install --list, by crate name, not by a binary on PATH. That is exact (a crate’s binary need not carry its name: ripgrep ships rg) and it is cheap, where an unconditional cargo install would rebuild every crate that has a newer release on each run. The flip side is that a binary obtained any other way — a prebuilt tarball, a distro package — is not in the listing and is treated as missing; the install passes --force so such a binary is replaced rather than refused with “binary already exists in destination” (a CI cache holding a prebuilt nextest did exactly that). doctor reports each declared crate the same way.

Components rustup manages (rustfmt, clippy) are not crates and do not belong in this list.

Install order

rsconstruct tool install-deps always installs in this fixed order:

  1. system — OS packages (apt, dnf, pacman, brew)
  2. pip — Python packages
  3. npm — Node.js packages (the global npm list, then the project’s npm ci)
  4. gem — Ruby gems
  5. cargo — crates from crates.io

This order is deliberate and must not be changed. Language-level packages frequently build native extensions that link against system libraries at install time. For example, installing manim via pip pulls in manimpango, which compiles a C extension and uses pkg-config to find pangocairo — so libpango1.0-dev must already be on the system before pip install runs. Running pip (or gem, or npm) before system causes wheel/extension builds to fail with messages like Package 'pangocairo' was not found.

The keys inside [dependencies] may appear in any order in rsconstruct.toml; the install order is enforced by install-deps regardless.

eatmydata wrapping

When eatmydata is installed on the system and CI=true is in the environment, both rsconstruct tool install and rsconstruct tool install-deps wrap their apt/dnf/pacman invocations with it. eatmydata no-op’s fsync() for the wrapped process, which speeds up package installs by 3–10×.

The trade-off is loss-on-power-cut: any package files written during the install are not flushed to disk, so a power loss mid-install can leave the package database inconsistent. That’s fine on transient CI hosts and wrong on developer workstations — hence the CI=true gate.

The wrap inserts eatmydata after sudo so the LD_PRELOAD applies to the package manager, not to sudo itself: e.g. sudo eatmydata apt-get install -y ….

If eatmydata is not installed, the commands run unwrapped — no error, no warning.

Controlling the wrap
To…Do this
Use the wrap (the CI default)Set CI=true and have eatmydata installed
Skip the wrap in CIUnset CI (or set it to anything other than true)
Use the wrap outside CIRun with CI=true rsconstruct tool install-deps
Skip the wrap for a single invocationPass --no-eatmydata

The CLI flag --no-eatmydata always wins. Otherwise the policy is driven entirely by CI=true. There is no rsconstruct.toml field for this — the env var is the knob.

The mechanism is a post-config hook: eatmydata_ci_default runs after config load and flips the in-memory dependencies.eatmydata flag when CI=true. List it with rsconstruct hook.

What’s never wrapped

brew is never wrapped (eatmydata is Linux-only). pip, npm, cargo, and gem are never wrapped either — those don’t fsync excessively, so the wrap adds nothing.

[pages]

Declares that this repo publishes a directory to GitHub Pages. The section is optional — its presence is the signal. rsconstruct page dir prints the directory when the section exists and prints nothing (exit 0) when it doesn’t, which lets a single shared CI workflow decide whether to run the Pages upload/deploy steps. See GitHub Actions for the workflow pattern.

KeyTypeDefaultDescription
dirstringrequiredDirectory whose contents are published to GitHub Pages (e.g., "out/web" or "_site").

Configures rsconstruct symlink-install, which symlinks every file under each source folder into the matching target folder, recreating the directory structure. sources[i] is installed into targets[i]; the command fails if the arrays differ in length or are empty. A leading ~/ in a target is expanded to $HOME. The section is read only by that command, never by a build.

KeyTypeDefaultDescription
sourcesstring[][]Folders (relative to the project root) whose files are linked.
targetsstring[][]Folders the links are created in, one per entry of sources.