Skip to main content

Module tour

A map of the crate for someone about to change it. Each section lists the items that matter and the reason they exist.

src/main.rs

CLI definition and dispatch. Also the crate root, so it declares the modules.

ItemNotes
CliTop-level clap parser. Global options: --format, --color, --verbose, --quiet
CommandRun, List, Check, Init, Revision, Update, Completions
SelectionArgsFlattened into all three selecting commands: paths, filter, tag, exclude, regex
RunArgsSelectionArgs plus jobs, fail_fast, timeout, retries, seed, dry_run, report
ListArgs, CheckArgs, InitArgsThe other commands' arguments
RevisionArgs, UpdateArgsRelease-manifest and self-update arguments
Format, ColorChoiceValue enums for --format and --color
ExitOk = 0, TestsFailed = 1, Error = 2, with From<Exit> for ExitCode
CtxPresentation settings resolved once: format, color as a bool, verbosity, quiet
implicit_run()Inserts the run verb in front of a leading path argument
is_broken_pipe()Walks the error chain so snag list | head exits 0
cmd_run / cmd_list / cmd_check / cmd_init / cmd_completionsOne handler per command

cmd_list owns its own rendering — it does not go through report.rs, because it describes tests rather than results.

cmd_init holds the two scaffold templates as const &str.

Change here when: adding a flag, a command, or altering how argv is rewritten.

src/discovery.rs

Turns paths into a filtered list of tests.

ItemNotes
discover()The shared entry point. Errors when no suite files were found
suite_files()Paths/globs/directories → sorted, de-duplicated manifest paths
is_glob()True when the string contains *, ?, or [
is_suite_file()suite.toml, snag.toml, *.snag.toml — applied only while walking
walk_for_suites()walkdir recursion, skipping dot-dirs and IGNORED_DIRS, no symlinks
IGNORED_DIRStarget, node_modules, dist, build, .venv
describe_search()Builds the "looked for …" half of the not-found error
FilterHolds include, exclude, and tags; accepts() applies them in that order
MatcherSubstrings(Vec<String>) or Regexes(RegexSet)

Filter::accepts() matches against [test.name, test.id], which is why -k finds either.

Change here when: adding a discovery convention, a filter dimension, or a new ignored directory.

src/manifest.rs

Schema and resolution.

ItemNotes
Manifesttitle, variables, timeout, setup, teardown, tests (renamed from test). deny_unknown_fields
TestDefinitionid, name, parallel_safe, tags, timeout, file, variables, setup, teardown. deny_unknown_fields
HookSpec / HookEntry / HookTableUntagged: a path, a { file, always } table, or a list of either
HookResolved hook — script plus always
resolve_hooks()Joins hook paths to the manifest directory; rejects always on setup
TestThe resolved form the rest of the crate uses
Test::qualified_id()format!("{}::{}", suite_path.display(), id)
load_suite()Read → parse → resolve, rejecting duplicate ids
normalize()Drops . components, leaves .. alone
default_title()"Untitled suite"

Variables are BTreeMap<String, String>, so ordering is deterministic and values are always strings.

Change here when: adding a manifest field. Remember deny_unknown_fields means old binaries reject manifests using a new field — a versioning consideration, not just a schema one.

src/runner.rs

Execution.

ItemNotes
run()Builds the reporter, shuffles, partitions, runs both phases, returns the exit
check()Compiles every script; prints per-test results; bails with a count
compile()Registers the same functions the runner does, then engine.compile()
build_reporter()Plain reporter, or a Multi when --report is given
jobs()0available_parallelism(), else the requested count
EventStarted(Test) / Finished(Box<Outcome>)
execute_batch()The worker pool and the event loop
execute()Retry wrapper; owns attempts and the total duration
execute_once()One attempt: build client and engine, compile, run setup/body/teardown, classify
compile_hooks() / compile_file()Compile hook and test scripts before anything runs
run_test_body()Setup scripts → fn setup() → the script, all sharing one Scope
run_teardown()on_teardown callbacks last-first → fn teardown() → teardown scripts
call_hook_fn()eval_ast(false), rewind_scope(false) so fn setup() leaves its variables behind
defines()Whether an AST declares a zero-argument function by that name
FailureTimedOut or Error(String)
classify()Distinguishes a timeout from a genuine script failure
phase_failure()Same classification, prefixed with the phase and script
shuffle()Fisher-Yates over a seeded xorshift64 — deterministic, no rng dependency
PROGRESS_INTERVAL2_000 operations between deadline checks

The deadline behind on_progress lives in a shared cell so the teardown phase can be handed a fresh budget — cleanup after a timed-out test would otherwise abort on its first operation.

build_reporter() is where --format human --report FILE becomes JSON in the file: a human report in a file would be useless to a tool.

Change here when: touching scheduling, retries, timeouts, or cancellation. execute_batch has a dedicated test module — extend it alongside any change.

src/report.rs

Statuses, the reporter trait, and the five formats.

ItemNotes
StatusPassed, Failed, TimedOut, Skipped, plus as_str() and is_failure()
OutcomeTest, status, message, duration, attempts, captured output
SummaryFour counters plus duration; record(), total(), is_green()
ReporterFour methods, all defaulting to no-ops
MultiBroadcasts every event to a Vec<Box<dyn Reporter>>
reporter_for()Factory from Format to a boxed reporter
HumanStreaming lines, a failure digest, and the summary
JsonlOne object per test, flushed; summary object last
JsonBuffers, emits snag.run/v1 in run_finished
TeamCityService messages; tc_escape() handles the spec's escapes
JunitBuffers, emits XML; xml_escape() also strips illegal control characters
outcome_json() / summary_json()Shared by Jsonl and Json, so the two cannot drift
fmt_duration()<1s123ms, otherwise 1.50s

Change here when: adding a format or a field. Adding a field to outcome_json() updates JSON and JSONL together; JUnit and TeamCity need their own edit.

src/scripting_registration.rs

The script-visible surface.

ItemNotes
OutputSinkArc<Mutex<Vec<String>>>, one per test
new_sink() / push()Create and append, ignoring a poisoned lock
ReqBuilderMethod, URL, headers, optional body, and a cloned client
ResponseStatus, text, headers, duration
register_http()Verbs, builder methods, send, accessors, field, basic
register_debug()on_print, on_debug, print_response
register_assertions()Every assert_*; eq_result() builds the message
register_env()env, env_or, sleep_ms, fail
register_teardown()Both on_teardown overloads, pushing onto the queue
TeardownCallback / TeardownQueueRc<RefCell<Vec<_>>> — a FnPtr belongs to its engine's thread
dig()Backs field(); walks maps and arrays by dotted path
json_to_dynamic() / dynamic_to_json_string()serde bridges for res.json() and .json(value)
truncate()Caps bodies in messages at 500 chars, appending the byte total

Builder methods take ReqBuilder by value and return it, which is what makes get(url).header(…).bearer(…).send() chain in Rhai.

register_type_with_name::<ReqBuilder>("Request") exists so error messages say Request, not a Rust path.

Change here when: adding a script function. See extending the script API.

Dependencies

CrateUsed for
clap + clap_completeCLI parsing and completions
anyhowError propagation with context
thiserrorDeclared; error types are currently anyhow-based
serde + toml + serde_jsonManifest parsing and report serialisation
humantime + humantime-serdeDuration parsing on the CLI and in manifests
rhaiThe scripting engine (serde and serde_json features)
reqwest (blocking)HTTP
regex--regex filters, via RegexSet
glob + walkdirPath expansion and directory walking
base64basic()

src/revision.rs

Builds the release manifest. See revision.json.

ItemNotes
VERSION / COMMIT / TARGETCompile-time constants; the last two come from build.rs
TARGETSThe release matrix. Must stay in step with the workflow build matrix
Revision / PlatformThe serialised shape. platforms is a BTreeMap, so output is byte-stable
latest_url() / tag_url()Manifest URLs. latest_url is version-free, so it resolves without knowing the tag
load()Reads a manifest from a file path or an http(s) URL
next_build() / resolve_build()The build counter, and the --build / --previous / --offline precedence

src/update.rs

snag update and the startup notice. See updating.

ItemNotes
parse_version() / is_newer()Numeric major.minor.patch compare. Unparseable never counts as newer
CachedThe check cache: checked_at, version, tag
cache_path() / read_cache() / write_cache()XDG_CACHE_HOME or LOCALAPPDATA; written via temp file + rename
is_stale()Ages an entry out after CHECK_TTL. Saturating, so a backwards clock cannot panic
roll_with() / roll()The NOTICE_CHANCE sampling, split from the clock so it is testable
enabled()Every gate on the notice: opt-out env, format, quiet, stderr TTY, command
start_check()Prints from cache, then refreshes on a detached thread. Never blocks the run
verify()Runs the download's --version and compares. The only integrity check there is
install()Atomic rename on Unix; move-aside-and-restore on Windows
denied()Whether the failure was a permission error, which is the only one that earns the sudo hint

Where to make a change

GoalFile
New CLI flagmain.rs (and the consuming module)
New manifest fieldmanifest.rs, then wherever Test is consumed
New discovery rulediscovery.rs
New script functionscripting_registration.rs
New assertionscripting_registration.rs, register_assertions
New output formatreport.rs plus the Format enum in main.rs
Scheduling / retries / timeoutsrunner.rs
New release platformTARGETS in revision.rs and the workflow build matrices
Update behaviour or the startup noticeupdate.rs