rust-script
Skill0 starsChanged 3 months agoMITView on GitHub
Write self-contained, portable, testable Rust command-line tools — a Cargo project with pure-logic/side-effect separation (boundaries as traits) that `cargo test` can drive with fakes, a clap CLI, and a build/test/clippy/fmt gate. Use as soon as the user wants a Rust script, CLI utility, automation tool, or "glue" code (orchestrating git, curl/http, file manipulation, parsing) in Rust, even if they never say "cargo". Also triggers on adding tests to an existing Rust tool, making one portable/testable, or porting a shell/Python script to Rust.
- Installs packages
In this repository7 other files it gives its agents.
- go-scriptSkill
- kotlin-scriptSkill
- python-scriptSkill
- scripteratopsSkill
- shell-scriptSkill
- swift-scriptSkill
- typescript-scriptSkill
The file
We split this skill into the rules it gives an agent
1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 204
Every module ships #[cfg(test)] mod tests in the same file (idiomatic Rust).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rust script
Standard for all Rust command-line tools: a small Cargo project whose logic
lives in a testable library, driven by a thin clap binary, verified by the
compiler + cargo test + clippy + rustfmt. The goal is a self-contained
tool (one static binary, no runtime to install) whose logic is trivially testable
— the Rust counterpart of the python-script, swift-script, kotlin-script,
typescript-script, and go-script skills.
Three decisions are already settled. Don't second-guess them, apply them:
- Distribution → a Cargo project compiled to a single static binary
(
cargo build --release) — like Go, no runtime, easy cross-compile. - Interface → clap (derive API).
- Tests →
cargo test(built-in#[test]), made easy by an architecture that isolates side effects behind traits.
This file carries the decisions; the code implementing them is a complete,
gate-passing project in assets/template/. Copy and adapt that project — don't
retype it from here. Then delete what your tool doesn't earn.
1. Project layout
tool/
├── Cargo.toml # deps (pinned in Cargo.lock — commit it, it's a binary)
├── Cargo.lock
├── src/
│ ├── lib.rs # module tree → LIBRARY crate (testable, `use tool::…`)
│ ├── main.rs # clap wiring → thin binary
│ ├── errors.rs # ToolError enum (thiserror) + exit_code()
│ ├── logic.rs # pure transform (+ #[cfg(test)] tests)
│ ├── boundaries.rs # CommandRunner / HttpFetcher / EnvironmentReading traits
│ ├── config.rs # Config + precedence + JSON file read (+ tests)
│ ├── color.rs # ColorMode + should_color (+ tests)
│ ├── log.rs # net-verbosity → level; stderr Logger
│ ├── runner.rs # std::process subprocess boundary
│ ├── http.rs # ureq boundary
│ └── work.rs # orchestration (do_work) (+ tests)
└── README.mdThe lib/bin split is the whole point: the binary can't be unit-tested as easily
as a library, so all logic lives in the library crate (src/lib.rs + modules),
and src/main.rs only parses args and wires things together (use tool::…).
- Pin every dependency —
cargo addwrites exact versions toCargo.tomland locks them inCargo.lock; commitCargo.lock(this is a binary). - Rust's stdlib has no HTTP and no JSON, so those earn a crate. The kept set:
clap (CLI), serde/serde_json (config), ureq (HTTP, blocking —
no async runtime), thiserror (typed error enum). Subprocess (
std::process) and env (std::env) are stdlib.
2. Structure
The structure is designed to make tests trivial: pure logic never touches the
outside world directly. Everything that leaves the process (subprocess, network,
filesystem, environment) sits behind a trait boundary, injected into the
orchestration. Tests pass a fake impl; production passes the real one.
- Boundaries are traits —
CommandRunner,HttpFetcher,EnvironmentReading. These are the single mock hooks. A fake is a tiny struct with one method. - Pure logic (
transform) — input → output, no I/O, tested by direct call. - Config is a
serdestruct withOptionfields (so "absent" is distinct from "empty"); precedence is one pure function,resolve_config, usingOption::or. - Orchestration (
do_work) assembles boundary calls + pure logic: data positional, behaviour flags last, returnsResult<usize, ToolError>. It takes the boundaries as&dyn Trait, so a test drives it with no process, no socket. main.rsis thin: parse args, build the real boundaries, calldo_work, map the error to an exit code. Nothing worth testing lives here.
2.1 Errors with thiserror
ToolError is an enum with one variant per failure category, deriving
thiserror::Error for the Display/Error impls, plus an exit_code() method
that keeps the process-exit contract with the category. Every fallible function
returns Result<_, ToolError>; ? propagates.
2.2 Subprocess boundary
ProcessRunner routes all subprocess calls through std::process::Command:
- Never a shell. Pass the program + an args slice — no
sh -c, so no injection from a filename or argument. - Always a timeout. Rust's std has no built-in process timeout, so the
template drains both pipes on background threads and polls
try_waitagainst a deadline; on deadline itkill()s the child and returnsTimeout(exit 124). (For a crate-based alternative,wait-timeoutreplaces the poll loop.) - On non-zero exit, return
Command { command, code, stderr }.
2.3 Config and precedence
Resolve settings with an explicit, documented precedence — higher wins:
CLI flags > environment variables > config file > built-in defaultsresolve_config is pure (Option::or-chains the layers so an absent value never
clobbers a lower layer). Each source is read by one boundary — read_config_file
(JSON via serde), config_from_environment (through the EnvironmentReading
hook). Secrets get no CLI flag (argv is world-readable via ps); a token
comes from the environment or config file. Never log a raw Config — route it
through redacted(…).
2.4 HTTP requests
UreqClient wraps ureq in one boundary:
- Set an explicit timeout on the agent (
timeout_global). - Non-2xx is an error — ureq returns
Error::StatusCodeon 4xx/5xx by default; map it to a typedHttp { url, status }. - Inject a fake
HttpFetcherto test offline. ureq is blocking (no tokio), which keeps the boundary a plain synchronous method.
2.5 Colorized logging
Logging uses the template's own Logger, constructed once in main.rs and
writing to stderr (never stdout — stdout is for program data). Color:
should_color(mode, is_tty, env)is a pure decision — precedence--color=always/never>NO_COLOR/FORCE_COLOR> TTY (and notTERM=dumb).- TTY detection in
main.rsusesstd::io::IsTerminal(stdlib, no dependency). - The
Loggerholds its sink in aRefCell<Box<dyn Write>>so&selfmethods can write and tests can injectio::sink().
2.6 Robustness & signals
- Bound every subprocess and HTTP call with a timeout (§2.2, §2.4).
- Signals: Rust installs no handlers by default, so SIGINT/SIGTERM terminate
with the OS default exit codes (130 / 143) — no handler needed for the baseline.
SIGPIPE caveat: Rust ignores SIGPIPE at startup, so writing to a closed
downstream pipe (
tool | head) surfaces as anio::Errorand aprintln!can panic with "Broken pipe". If your tool streams into pipes, handle the write error (or reset SIGPIPE to default via a smallunsafe/libccall). - Preflight external tools for glue scripts (a
which-style check up front), returningConfig(...)listing every missing tool.
2.7 Subcommands (if needed)
clap derive handles this natively: an enum with #[derive(Subcommand)] as a
#[command(subcommand)] field on the root Cli. No manual dispatch.
2.8 Man page (optional)
clap can emit man pages via the clap_mangen crate, but the simplest path (no
extra dep) is help2man from the built binary's --help:
help2man --no-info --name "tool" "target/release/tool" > tool.1
sudo install -m 644 tool.1 /usr/local/share/man/man1/tool.12.9 Build & install (optional, for tools you distribute)
The README carries the environment set-up, not just the gates: a
Prerequisites section with per-platform install of the Rust toolchain
(macOS/Linux/Windows) and the dependency list, pointing to Cargo.toml +
Cargo.lock as the pinned source. For a distributed binary, an optional
--dependencies flag printing the pinned versions is a nice touch — skip it for
source-run tools.
- Single binary:
cargo build --release→target/release/tool(static, self-contained).cargo install --path .drops it in~/.cargo/bin. - Cross-compile:
cargo build --release --target <triple>(add the target withrustup target add). - Run from source:
cargo run -- <args>.
3. CLI contract
clap (derive) gives most of the baseline for free:
-h/--helpand-V/--versionare automatic (#[command(version)]).- Verbosity:
#[arg(short, long, action = ArgAction::Count)] verbose: u8counts occurrences; same forquiet. Map the net count withlog_level_for_verbosity(verbose as i32 - quiet as i32). --coloris a CLI-local#[derive(ValueEnum)]mapped into the coreColorModeviaFrom— this keeps clap out of the library.--dry-runshort-circuits before output, announced at warning level.- Stream discipline: program data → stdout (
println!); logs, progress, diagnostics → stderr (theLogger). - clap groups options cleanly in
--helpby default; usehelp_headingon args if you want explicit sections.
Exit codes
main returns ExitCode; on error it logs and returns ExitCode::from(code).
| Code | Meaning |
0 | Success (also --help/--version) |
1 | Runtime error (a ToolError); clap also uses 2 for usage errors |
2 | Usage error (clap emits this itself) |
124 | A subprocess exceeded its timeout (Timeout) |
130 | Interrupted — SIGINT / Ctrl-C (OS default) |
143 | Terminated — SIGTERM / kill (OS default) |
ToolError::exit_code() keeps the code with the failure category. (clap is the
one CLI here that natively uses the conventional 2 for usage errors.)
4. Tests with cargo test
Every module ships #[cfg(test)] mod tests in the same file (idiomatic Rust).
- Never test subprocess or network for real — inject a fake
implof the boundary trait. That's what the trait is for. - Fakes are tiny structs (
struct FakeRunner(String);with one method), not a mocking framework. - Pure logic and precedence are tested by direct call — no fakes at all.
- Assert with
assert_eq!/assert!; theLoggertakes an injectable sink (io::sink()) so orchestration tests stay silent.
Coverage priority: pure logic and orchestration first, then config precedence, then arg parsing.
5. Quality gates
Four gates, all from the Rust toolchain — no extra installs:
cargo fmt --check # formatting: rustfmt
cargo clippy --all-targets -- -D warnings # lint: warnings are errors
cargo build # compiles — the compiler IS the type gate
cargo test # tests- Indentation is 4-space (rustfmt default). Like Go's tabs, this is the
ecosystem standard — don't fight it;
cargo fmtfixes layout. - Treat clippy warnings as errors (
-D warnings) — it's the real lint gate, and it will suggest idioms (collapsedif let … && …, etc.).
Summary of what to produce
Faced with a Rust tool request, produce:
- A Cargo project copied from
assets/template/: logic + boundaries in the library, a thin clapmain.rs, deps pinned viacargo add(+Cargo.lock), the §2 structure, the §3 CLI contract and exit codes, and the robustness rules the tool's job earns (shelling out earns the timeout + preflight; the network earns the HTTP boundary + timeout; holding config/secrets earns precedence + redaction). Delete what it doesn't earn. #[cfg(test)] mod testscovering pure logic, config precedence, orchestration (with fakes), and any boundary the tool ships.- In one line: how to run it (
cargo run -- <args>), and that the four gates —fmt+clippy+build+test— are green.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

