pub struct Verdict {
pub code: u8,
pub text: String,
}
const REFUSED: u8 = 2;
const FAILED: u8 = 1;
impl Verdict {
pub fn ok(text: String) -> Self {
Self { code: 0, text }
}
pub fn refused(what: String) -> Self {
Self {
code: REFUSED,
text: format!("thrall: {what}\n\n{}", usage()),
}
}
pub fn failed(what: String) -> Self {
Self {
code: FAILED,
text: format!("thrall: {what}"),
}
}
}
pub enum Decided {
Say(Verdict),
Serve,
}
pub fn version() -> String {
format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
pub fn usage() -> String {
format!(
"{}
thrall is the foot: a tool-execution client for a yog server. It dials in,
advertises what this box offers, waits for work, and posts the captures back.
It never listens and it never speaks first.
usage: thrall run
thrall [--version | --help]
run serve every channel this box is provisioned for: present
what it offers, wait for work, run it, post the captures
back. It does not return while a channel is up, and it
never reconnects: a channel that fails is an exit naming
it, and restarting belongs to this machine's own
supervision.
-V, --version print the name and version
-h, --help print this
What it reads, both provisioned by hand and never by thrall: the tool document
at <data root>/tools.json, and one channel per directory under <data
root>/wire/workspaces/. The data root is $XDG_DATA_HOME/thrall, or
$HOME/.local/share/thrall.
See docs/DESIGN.md for the role and the module map, and yog's docs/REMOTE.md
for the protocol thrall implements against.",
version()
)
}
pub fn run(args: Vec<String>) -> Decided {
let words: Vec<&str> = args.iter().map(String::as_str).collect();
match words.as_slice() {
["run"] => Decided::Serve,
["--version" | "-V"] => Decided::Say(Verdict::ok(version())),
["--help" | "-h"] => Decided::Say(Verdict::ok(usage())),
[] => Decided::Say(Verdict::refused(
"nothing to do — `thrall run` is the verb".to_string(),
)),
other => Decided::Say(Verdict::refused(format!(
"unrecognised argument: {}",
other.join(" ")
))),
}
}
#[cfg(test)]
mod tests {
use super::{Decided, FAILED, REFUSED, Verdict, run, usage, version};
fn argv(words: &[&str]) -> Vec<String> {
words.iter().map(|w| (*w).to_string()).collect()
}
fn said(words: &[&str]) -> Verdict {
match run(argv(words)) {
Decided::Say(verdict) => verdict,
Decided::Serve => panic!("{words:?} decided to serve"),
}
}
#[test]
fn version_names_the_crate_and_its_version() {
assert_eq!(version(), format!("thrall {}", env!("CARGO_PKG_VERSION")));
}
#[test]
fn usage_leads_with_the_version_line_then_says_what_thrall_is() {
let text = usage();
assert!(
text.starts_with(&version()),
"usage did not lead with the version: {text}"
);
assert!(
text.contains("thrall is the foot"),
"usage did not say what thrall is"
);
assert!(
text.contains("never speaks first"),
"usage dropped the dial-in invariant"
);
}
#[test]
fn usage_names_the_verb_and_what_it_reads() {
let text = usage();
assert!(text.contains("thrall run"), "{text}");
assert!(text.contains("tools.json"), "{text}");
assert!(text.contains("wire/workspaces/"), "{text}");
assert!(text.contains("never reconnects"), "{text}");
}
#[test]
fn ok_carries_the_success_code() {
let v = Verdict::ok("said".to_string());
assert_eq!(v.code, 0);
assert_eq!(v.text, "said");
}
#[test]
fn every_refusal_names_what_it_refused_and_still_teaches() {
let v = Verdict::refused("that is not a verb".to_string());
assert_eq!(v.code, REFUSED);
assert_eq!(v.text, format!("thrall: that is not a verb\n\n{}", usage()));
}
#[test]
fn a_failure_says_only_what_happened() {
let v = Verdict::failed("this box holds no channel".to_string());
assert_eq!(v.code, FAILED);
assert_eq!(v.text, "thrall: this box holds no channel");
assert!(!v.text.contains("usage:"), "{}", v.text);
}
#[test]
fn both_version_spellings_print_the_version_and_succeed() {
for spelling in ["--version", "-V"] {
let v = said(&[spelling]);
assert_eq!(v.code, 0, "{spelling} did not succeed");
assert_eq!(v.text, version(), "{spelling} printed something else");
}
}
#[test]
fn both_help_spellings_print_the_usage_and_succeed() {
for spelling in ["--help", "-h"] {
let v = said(&[spelling]);
assert_eq!(v.code, 0, "{spelling} did not succeed");
assert_eq!(v.text, usage(), "{spelling} printed something else");
}
}
#[test]
fn the_verb_decides_to_serve() {
assert!(matches!(run(argv(&["run"])), Decided::Serve));
}
#[test]
fn a_bare_invocation_refuses_and_names_the_verb() {
let v = said(&[]);
assert_eq!(v.code, REFUSED);
assert!(v.text.contains("`thrall run` is the verb"), "{}", v.text);
assert!(
v.text.contains("usage: thrall"),
"a refusal must still teach: {}",
v.text
);
}
#[test]
fn an_unrecognised_argument_refuses_and_quotes_every_word_of_it() {
let v = said(&["seat", "--ws", "Example"]);
assert_eq!(v.code, REFUSED);
assert!(
v.text.contains("unrecognised argument: seat --ws Example"),
"the refusal did not name what it refused: {}",
v.text
);
assert!(v.text.contains("usage: thrall"), "{}", v.text);
}
#[test]
fn a_recognised_word_with_extra_words_is_not_recognised() {
for extra in [["--version", "--now"], ["run", "--now"]] {
let v = said(&extra);
assert_eq!(v.code, REFUSED);
assert!(
v.text
.contains(&format!("unrecognised argument: {}", extra.join(" "))),
"{}",
v.text
);
}
}
}