#![cfg(target_os = "linux")]
mod common;
use common::{repository, Root};
use std::collections::BTreeSet;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Clone, Copy)]
enum At {
Repository,
Scratch,
Fresh,
Nowhere,
}
struct Row {
verb: Option<&'static str>,
at: At,
args: &'static [&'static str],
input: Option<&'static str>,
}
const fn row(verb: &'static str, at: At, args: &'static [&'static str]) -> Row {
Row {
verb: Some(verb),
at,
args,
input: None,
}
}
const ROWS: &[Row] = &[
row("check", At::Scratch, &["check"]),
row("change", At::Repository, &["change"]),
row("gate", At::Repository, &["gate"]),
row("conformance", At::Scratch, &["conformance"]),
row("derived", At::Scratch, &["derived"]),
row("merge-driver", At::Repository, &["merge-driver"]),
row(
"route",
At::Repository,
&["route", "write", "a", "decision"],
),
row(
"route",
At::Repository,
&["route", "--json", "write", "a", "decision"],
),
row(
"neighbors",
At::Repository,
&["neighbors", "write", "a", "decision"],
),
row("explain", At::Repository, &["explain", "HW-DR-0049"]),
row("query", At::Repository, &["query", "kind"]),
row("capture", At::Scratch, &["capture"]),
Row {
verb: Some("mcp"),
at: At::Repository,
args: &["mcp"],
input: Some("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n"),
},
row("new", At::Scratch, &["new"]),
row("infer", At::Scratch, &["infer"]),
row("generate", At::Scratch, &["generate", "--check"]),
row("import", At::Scratch, &["import"]),
row("export", At::Scratch, &["export"]),
row("sweep", At::Repository, &["sweep"]),
row("sweep", At::Scratch, &["sweep", "plan"]),
row("probe", At::Repository, &["probe"]),
row("init", At::Fresh, &["init"]),
row("taxonomy", At::Scratch, &["taxonomy", "validate"]),
Row {
verb: Some("json"),
at: At::Nowhere,
args: &["json", "field", "a"],
input: Some("{\"a\":1}\n"),
},
row("help", At::Nowhere, &["help"]),
row("help", At::Nowhere, &["help", "explain"]),
row("completions", At::Nowhere, &["completions", "zsh"]),
row("completions", At::Nowhere, &["completions", "bash"]),
Row {
verb: None,
at: At::Nowhere,
args: &["--help"],
input: None,
},
Row {
verb: None,
at: At::Nowhere,
args: &["--version"],
input: None,
},
];
#[derive(Clone, Copy, Debug, PartialEq)]
enum Full {
Neither,
Stdout,
Stderr,
}
struct Ran {
code: Option<i32>,
out: Vec<u8>,
err: Vec<u8>,
}
fn full() -> std::fs::File {
std::fs::File::options()
.write(true)
.open("/dev/full")
.expect("/dev/full opens")
}
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl std::ops::Deref for Scratch {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl AsRef<Path> for Scratch {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl AsRef<std::ffi::OsStr> for Scratch {
fn as_ref(&self) -> &std::ffi::OsStr {
self.0.as_os_str()
}
}
fn fresh() -> Scratch {
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT: AtomicUsize = AtomicUsize::new(0);
let at = std::env::temp_dir().join(format!(
"headwater-unwritable-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::SeqCst)
));
let _ = std::fs::remove_dir_all(&at);
std::fs::create_dir_all(at.join("docs")).expect("the fresh root is made");
std::fs::write(at.join("docs/a.md"), "# A\n\nOne paragraph.\n").expect("the document writes");
Scratch(at)
}
fn run(row: &Row, root: Option<&Path>, which: Full) -> Ran {
let mut command = Command::new(env!("CARGO_BIN_EXE_headwater"));
command.args(row.args);
if let Some(root) = root {
command.arg("--root").arg(root);
}
command.stdin(match row.input {
Some(_) => Stdio::piped(),
None => Stdio::null(),
});
match which {
Full::Neither => command.stdout(Stdio::piped()).stderr(Stdio::piped()),
Full::Stdout => command.stdout(full()).stderr(Stdio::piped()),
Full::Stderr => command.stdout(Stdio::piped()).stderr(full()),
};
let mut child = command.spawn().expect("the binary runs");
if let Some(input) = row.input {
let mut stdin = child.stdin.take().expect("standard input is piped");
let _ = stdin.write_all(input.as_bytes());
}
let output = child.wait_with_output().expect("the binary finishes");
Ran {
code: output.status.code(),
out: output.stdout,
err: output.stderr,
}
}
#[test]
fn every_verb_exits_1_and_never_101_when_a_stream_it_writes_is_full() {
let named: BTreeSet<&str> = ROWS.iter().filter_map(|row| row.verb).collect();
let carried: BTreeSet<&str> = headwater_verbs::VERBS
.iter()
.map(|verb| verb.name)
.collect();
assert_eq!(
named, carried,
"every verb this binary carries has a row in this table, and no row names a verb it does not carry"
);
let scratch = Root::new("unwritable");
let repository = repository();
let mut wrong = Vec::new();
let mut filled_stdout = 0;
let mut made = Vec::new();
for row in ROWS {
let label = row.args.join(" ");
let mut root = || -> Option<PathBuf> {
match row.at {
At::Repository => Some(repository.clone()),
At::Scratch => Some(scratch.at.clone()),
At::Fresh => {
let at = fresh();
let path = at.to_path_buf();
made.push(at);
Some(path)
}
At::Nowhere => None,
}
};
let control = run(row, root().as_deref(), Full::Neither);
assert_ne!(
control.code,
Some(101),
"`{label}` panics with both streams writable: {}",
String::from_utf8_lossy(&control.err)
);
for which in [Full::Stdout, Full::Stderr] {
let ran = run(row, root().as_deref(), which);
let said = String::from_utf8_lossy(match which {
Full::Stdout => &ran.err,
_ => &ran.out,
})
.into_owned();
let wrote = match which {
Full::Stdout => !control.out.is_empty(),
_ => !control.err.is_empty(),
};
let mut fault = Vec::new();
if ran.code == Some(101) {
fault.push("exited 101".to_string());
}
if wrote && ran.code != Some(1) {
fault.push(format!("exited {:?} and not 1", ran.code));
}
if said.contains("panicked") {
fault.push("printed `panicked`".to_string());
}
if which == Full::Stdout && wrote {
filled_stdout += 1;
if !said.contains("standard output") {
fault.push("did not name standard output on standard error".to_string());
}
}
if !fault.is_empty() {
wrong.push(format!(
"`{label}` with {which:?} full: {}. It said: {}",
fault.join(", "),
said.trim_end()
));
}
}
}
drop(made);
assert!(
wrong.is_empty(),
"{} of {} runs met a full stream wrongly:\n{}",
wrong.len(),
ROWS.len() * 2,
wrong.join("\n")
);
assert!(
filled_stdout >= 5,
"the table filled standard output on a run that writes there {filled_stdout} times, \
and help, explain, completions, derived and route each owe one"
);
}
#[test]
fn a_reader_that_closes_early_does_not_make_a_verb_panic() {
let repository = repository();
let cases: [(&str, Vec<&std::ffi::OsStr>); 2] = [
(
"completions zsh",
vec!["completions".as_ref(), "zsh".as_ref()],
),
(
"sweep plan",
vec![
"sweep".as_ref(),
"plan".as_ref(),
"--root".as_ref(),
repository.as_os_str(),
],
),
];
for (label, args) in cases {
let mut child = Command::new(env!("CARGO_BIN_EXE_headwater"))
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
let mut stdout = child.stdout.take().expect("standard output is piped");
let mut first = [0u8; 16];
stdout
.read_exact(&mut first)
.expect("the verb writes its first bytes");
drop(stdout);
let output = child.wait_with_output().expect("the binary finishes");
let said = String::from_utf8_lossy(&output.stderr).into_owned();
assert_eq!(
output.status.code(),
Some(1),
"`{label}` read by a reader that closed early exits 1: {said}"
);
assert!(
!said.contains("panicked"),
"`{label}` did not panic: {said}"
);
assert!(
said.contains("standard output"),
"`{label}` names standard output on standard error: {said}"
);
}
}