#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Outcome {
Ok,
Error,
Usage,
Yanked,
NoMatch,
}
impl Outcome {
pub fn code(self) -> u8 {
match self {
Outcome::Ok => 0,
Outcome::Error => 1,
Outcome::Usage => 2,
Outcome::Yanked => 3,
Outcome::NoMatch => 4,
}
}
pub fn name(self) -> &'static str {
match self {
Outcome::Ok => "ok",
Outcome::Error => "error",
Outcome::Usage => "usage",
Outcome::Yanked => "yanked",
Outcome::NoMatch => "no-match",
}
}
pub fn meaning(self) -> &'static str {
match self {
Outcome::Ok => "the command did what it was asked",
Outcome::Error => {
"varve refused or failed — an unverifiable layer, a missing pin, an unreadable \
file, a rollback, a shadowed PATH. varve fails closed, so every \"cannot vouch \
for it\" lands here"
}
Outcome::Usage => {
"the command line was wrong (unknown flag, unknown subcommand, missing \
argument) — clap's own code. Distinct from 1 so a pipeline does not report a \
typo as a verification failure"
}
Outcome::Yanked => {
"the pinned layer is YANKED by a signed line-status document. varve answered; \
the answer stops the build"
}
Outcome::NoMatch => "the query ran and matched nothing",
}
}
pub fn commands(self) -> &'static [&'static str] {
match self {
Outcome::Ok | Outcome::Error | Outcome::Usage => &[],
Outcome::Yanked => &["status"],
Outcome::NoMatch => &["docs --grep"],
}
}
}
pub const CONTRACT: &[Outcome] = &[
Outcome::Ok,
Outcome::Error,
Outcome::Usage,
Outcome::Yanked,
Outcome::NoMatch,
];
fn scope(o: Outcome) -> String {
match o.commands() {
[] => "any command".to_string(),
cs => cs
.iter()
.map(|c| format!("`varve {c}`"))
.collect::<Vec<_>>()
.join(", "),
}
}
pub fn render_text() -> String {
let mut out = String::from("varve exit codes — the contract a pipeline gates on\n\n");
for o in CONTRACT {
out.push_str(&format!(
" {code} {name:<9} {scope}\n {meaning}\n",
code = o.code(),
name = o.name(),
scope = scope(*o),
meaning = o.meaning(),
));
}
out.push_str(
"\nAny code varve returns is one of these. A future code is additive; the meaning of a \
code already listed here does not change.\n",
);
out
}
pub fn render_json() -> String {
let codes: Vec<_> = CONTRACT
.iter()
.map(|o| {
serde_json::json!({
"code": o.code(),
"name": o.name(),
"meaning": o.meaning(),
"commands": o.commands(),
})
})
.collect();
serde_json::to_string_pretty(&serde_json::json!({ "codes": codes }))
.expect("the exit-code contract serialises")
}
pub fn render_topic() -> String {
let mut out = String::from(
"# Exit codes\n\n\
Every `varve` process exits with one of the codes below. They are a compatibility \
promise: a future release may ADD a code, but will not change what one already listed \
here means.\n\n\
This page is generated from the same table the binary exits with, and every code in it \
is checked against the real exit status of a real invocation by the test suite — so it \
cannot drift from what varve does.\n\n\
| code | name | produced by | meaning |\n|---|---|---|---|\n",
);
for o in CONTRACT {
out.push_str(&format!(
"| {} | `{}` | {} | {} |\n",
o.code(),
o.name(),
scope(*o),
o.meaning(),
));
}
out.push_str(
"\n## The consumer gate\n\n\
This is the CI gate the `ci`, `status` and `verify` topics tell you to write. It is \
three commands and no output scraping — the exit code is the whole contract.\n\n\
```sh\n\
set -e\n\
varve install # fetch + verify the pinned layer\n\
varve verify # re-check it offline, including PATH shadowing\n\
\n\
# A yanked layer must stop the build. `status` exits 3 when the pinned\n\
# layer is yanked by a signed line-status document, 0 when it is not,\n\
# and 1 when it could not answer at all — three outcomes, three codes.\n\
varve status\n\
```\n\n\
Branch on the yank rather than aborting on it:\n\n\
```sh\n\
varve status || case $? in\n\
\u{20} 3) echo \"pinned layer is yanked — refusing to build\" >&2; exit 1 ;;\n\
\u{20} *) echo \"varve status could not answer\" >&2; exit 1 ;;\n\
esac\n\
```\n\n\
Machine-readable, for a pipeline that wants the table itself:\n\n\
```sh\n\
varve exit-codes --json\n\
```\n\n\
## What is NOT an exit code\n\n\
`varve docs --grep` exits 4 when nothing matches, so a documentation check can gate; it \
is not an error, and nothing is written to stderr. Likewise `varve status` exiting 3 \
is an ANSWER, not a failure — stdout still carries the full report.\n",
);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn index_in_contract(o: Outcome) -> usize {
match o {
Outcome::Ok => 0,
Outcome::Error => 1,
Outcome::Usage => 2,
Outcome::Yanked => 3,
Outcome::NoMatch => 4,
}
}
#[test]
fn the_contract_holds_every_outcome_the_binary_can_return() {
for (i, o) in CONTRACT.iter().enumerate() {
assert_eq!(
index_in_contract(*o),
i,
"CONTRACT is out of step with the Outcome enum at {o:?}"
);
}
}
#[test]
fn every_code_is_distinct_and_named() {
let mut seen = std::collections::BTreeSet::new();
for o in CONTRACT {
assert!(
seen.insert(o.code()),
"exit code {} is claimed by two outcomes",
o.code()
);
assert!(!o.name().is_empty());
assert!(
o.meaning().len() > 20,
"{} needs a meaning a pipeline author can act on",
o.name()
);
}
assert_eq!(Outcome::Ok.code(), 0, "success is 0, forever");
}
#[test]
fn the_generated_topic_lists_every_code_and_shows_a_gate() {
let topic = render_topic();
for o in CONTRACT {
assert!(
topic.contains(&format!("| {} | `{}` |", o.code(), o.name())),
"the generated topic omits exit code {}",
o.code()
);
}
assert!(topic.contains("varve status"));
assert!(topic.contains("case $? in"));
assert!(topic.to_lowercase().contains("exit code"));
}
#[test]
fn the_json_contract_is_a_stable_shape() {
let v: serde_json::Value = serde_json::from_str(&render_json()).unwrap();
let codes = v["codes"].as_array().expect("`codes` is an array");
assert_eq!(codes.len(), CONTRACT.len());
for c in codes {
assert!(c["code"].is_u64(), "`code` is a NUMBER, not a string");
assert!(c["name"].is_string());
assert!(c["meaning"].is_string());
assert!(c["commands"].is_array());
}
assert_eq!(codes[0]["code"], 0);
assert_eq!(codes[0]["name"], "ok");
}
#[test]
fn no_new_export_adapter_ships_without_a_system_test() {
use clap::CommandFactory;
let adapters: Vec<String> = crate::Cli::command()
.get_subcommands()
.map(|c| c.get_name().to_string())
.filter(|n| n.starts_with("export-"))
.collect();
assert!(
adapters.len() >= 6,
"the adapter enumeration found {} — if clap's shape changed this test \
is measuring nothing: {adapters:?}",
adapters.len()
);
let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."));
let mut gate_text = String::new();
for rel in [
"tools/systest/selfhost.sh",
"tools/systest/deposit-layer.sh",
"tools/systest/oci-roundtrip.sh",
".github/workflows/systest.yml",
] {
gate_text.push_str(&std::fs::read_to_string(root.join(rel)).unwrap_or_default());
}
assert!(
gate_text.contains("export-cargo"),
"the system-gate sources could not be read — this test would then \
report every adapter as uncovered, which is a different lie"
);
let mut by_len = adapters.clone();
by_len.sort_by_key(|a| std::cmp::Reverse(a.len()));
let uncovered: std::collections::BTreeSet<String> = adapters
.iter()
.filter(|a| {
!gate_text.match_indices(a.as_str()).any(|(i, _)| {
gate_text[i + a.len()..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric() && c != '-')
})
})
.cloned()
.collect();
let known_uncovered: std::collections::BTreeSet<String> =
["export-bazel", "export-bazel-distdir", "export-sdk"]
.iter()
.map(|s| s.to_string())
.collect();
let regressed: Vec<_> = uncovered.difference(&known_uncovered).collect();
assert!(
regressed.is_empty(),
"export adapter(s) {regressed:?} have no system test and are not in the \
recorded gap. REQ-SYSTEST-002 clause 5: no adapter ships without one. \
Add a gate under tools/systest/, or add it to `known_uncovered` here \
with a reason — deliberately, in a reviewed diff."
);
let fixed: Vec<_> = known_uncovered.difference(&uncovered).collect();
assert!(
fixed.is_empty(),
"{fixed:?} now HAS a system test but is still listed as a known gap — \
remove it from `known_uncovered`, so the list keeps meaning what it says"
);
}
}